Skip to content

Commit

Permalink
Create a dedicated ViewModel for MediaRouteButton
Browse files Browse the repository at this point in the history
  • Loading branch information
MGaetan89 committed Feb 18, 2025
1 parent 9fb8838 commit 4add9ee
Show file tree
Hide file tree
Showing 9 changed files with 451 additions and 166 deletions.
4 changes: 2 additions & 2 deletions .github/workflows/quality.yml
Original file line number Diff line number Diff line change
Expand Up @@ -70,12 +70,12 @@ jobs:
- name: Set up Gradle
uses: gradle/actions/setup-gradle@v4
- name: Run Unit Tests
run: ./gradlew koverXmlReport
run: ./gradlew koverXmlReportDebug
- name: Report Code Coverage
if: ${{ github.event_name == 'pull_request' }}
uses: madrapps/[email protected]
with:
paths: ${{ github.workspace }}/**/build/reports/kover/report.xml
paths: ${{ github.workspace }}/**/build/reports/kover/reportDebug.xml
token: ${{ secrets.GITHUB_TOKEN }}
min-coverage-overall: 0
min-coverage-changed-files: 0
Expand Down
7 changes: 7 additions & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,18 @@ androidx-activity = "1.10.0"
androidx-compose = "2025.02.00"
androidx-core = "1.15.0"
androidx-fragment = "1.8.6"
androidx-lifecycle = "2.8.7"
androidx-mediarouter = "1.7.0"
androidx-test-core = "1.6.1"
androidx-test-ext-junit = "1.2.1"
coil = "3.1.0"
detekt = "1.23.7"
junit = "4.13.2"
kotlin = "2.1.10"
kotlinx-coroutines = "1.10.1"
kotlinx-kover = "0.9.1"
robolectric = "4.14.1"
turbine = "1.2.0"

[libraries]
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "androidx-activity" }
Expand All @@ -26,6 +29,8 @@ androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-toolin
androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
androidx-core = { group = "androidx.core", name = "core-ktx", version.ref = "androidx-core" }
androidx-fragment = { group = "androidx.fragment", name = "fragment", version.ref = "androidx-fragment" }
androidx-lifecycle-viewmodel = { group = "androidx.lifecycle", name = "lifecycle-viewmodel", version.ref = "androidx-lifecycle" }
androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "androidx-lifecycle" }
androidx-mediarouter = { group = "androidx.mediarouter", name = "mediarouter", version.ref = "androidx-mediarouter" }
androidx-test-core = { group = "androidx.test", name = "core", version.ref = "androidx-test-core" }
androidx-test-ext-junit = { group = "androidx.test.ext", name = "junit", version.ref = "androidx-test-ext-junit" }
Expand All @@ -34,7 +39,9 @@ coil-network-okhttp = { group = "io.coil-kt.coil3", name = "coil-network-okhttp"
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" }
kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "kotlinx-coroutines" }
robolectric = { group = "org.robolectric", name = "robolectric", version.ref = "robolectric" }
turbine = { group = "app.cash.turbine", name = "turbine", version.ref = "turbine" }

[plugins]
android-application = { id = "com.android.application", version.ref = "android-gradle-plugin" }
Expand Down
16 changes: 16 additions & 0 deletions mediarouter-compose/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,18 @@ tasks.withType<Test>().configureEach {
testLogging.exceptionFormat = TestExceptionFormat.FULL
}

kover {
reports {
filters {
excludes {
annotatedBy("androidx.compose.ui.tooling.preview.Preview")
classes("${android.namespace}.ComposableSingletons*")
inheritedFrom("androidx.compose.ui.tooling.preview.PreviewParameterProvider")
}
}
}
}

dependencies {
implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.compose.material3)
Expand All @@ -69,6 +81,8 @@ dependencies {
implementation(libs.androidx.compose.ui.tooling.preview)
debugImplementation(libs.androidx.compose.ui.tooling)
implementation(libs.androidx.core)
implementation(libs.androidx.lifecycle.viewmodel)
implementation(libs.androidx.lifecycle.viewmodel.compose)
implementation(libs.androidx.mediarouter)
implementation(libs.coil.compose)
implementation(libs.coil.network.okhttp)
Expand All @@ -78,7 +92,9 @@ dependencies {
testImplementation(libs.androidx.test.ext.junit)
testImplementation(libs.junit)
testImplementation(libs.kotlin.test)
testImplementation(libs.kotlinx.coroutines.test)
testImplementation(libs.robolectric)
testImplementation(libs.turbine)
}

publishing {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package ch.srgssr.androidx.mediarouter.compose

import androidx.annotation.StringRes
import androidx.mediarouter.R

enum class CastConnectionState(@StringRes val contentDescriptionRes: Int) {
Connected(R.string.mr_cast_button_connected),
Connecting(R.string.mr_cast_button_connecting),
Disconnected(R.string.mr_cast_button_disconnected),
}
Original file line number Diff line number Diff line change
Expand Up @@ -132,12 +132,6 @@ internal fun CastIcon(
}
}

internal enum class CastConnectionState {
Connected,
Connecting,
Disconnected,
}

@Composable
private fun InfiniteTransition.animateFloat(
state: CastConnectionState,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,25 +1,16 @@
package ch.srgssr.androidx.mediarouter.compose

import androidx.annotation.StringRes
import androidx.compose.material3.IconButton
import androidx.compose.material3.IconButtonColors
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.mediarouter.R
import androidx.mediarouter.app.SystemOutputSwitcherDialogController
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.mediarouter.media.MediaRouteSelector
import androidx.mediarouter.media.MediaRouter
import androidx.mediarouter.media.MediaRouter.RouteInfo
import androidx.mediarouter.media.MediaRouterParams
import ch.srgssr.androidx.mediarouter.compose.MediaRouteButtonViewModel.DialogType

/**
* The media route button allows the user to select routes and to control the currently selected
Expand Down Expand Up @@ -71,160 +62,29 @@ fun MediaRouteButton(
},
mediaRouteDynamicControllerDialog: @Composable () -> Unit = {}, // TODO
) {
var mediaRouterCallbackTriggered by remember { mutableIntStateOf(0) }
var showDialog by remember { mutableStateOf(false) }

val context = LocalContext.current
val router = remember { MediaRouter.getInstance(context) }
val mediaRouterCallback = rememberMediaRouterCallback { mediaRouterCallbackTriggered++ }
val connectionState by remember(mediaRouterCallbackTriggered) {
mutableStateOf(computeConnectionState(router))
}
val contentDescriptionRes = remember(connectionState) {
computeContentDescriptionRes(connectionState)
}

DisposableEffect(routeSelector) {
if (!routeSelector.isEmpty) {
router.addCallback(routeSelector, mediaRouterCallback)
}

onDispose {
if (!routeSelector.isEmpty) {
router.removeCallback(mediaRouterCallback)
}
}
}

if (showDialog) {
MediaRouteDialog(
router = router,
mediaRouteChooserDialog = mediaRouteChooserDialog,
mediaRouteDynamicChooserDialog = mediaRouteDynamicChooserDialog,
mediaRouteControllerDialog = mediaRouteControllerDialog,
mediaRouteDynamicControllerDialog = mediaRouteDynamicControllerDialog,
onDismissRequest = { showDialog = false },
)
}
val viewModel = viewModel<MediaRouteButtonViewModel>(
key = routeSelector.toString(),
factory = MediaRouteButtonViewModel.Factory(routeSelector),
)
val castConnectionState by viewModel.castConnectionState.collectAsState()
val dialogType by viewModel.dialogType.collectAsState(DialogType.None)

IconButton(
onClick = { showDialog = true },
onClick = viewModel::showDialog,
modifier = modifier,
colors = colors,
) {
CastIcon(
state = connectionState,
contentDescription = stringResource(contentDescriptionRes),
state = castConnectionState,
contentDescription = stringResource(castConnectionState.contentDescriptionRes),
)
}
}

@Composable
private fun MediaRouteDialog(
router: MediaRouter,
mediaRouteChooserDialog: @Composable (onDismissRequest: () -> Unit) -> Unit,
mediaRouteDynamicChooserDialog: @Composable () -> Unit,
mediaRouteControllerDialog: @Composable (onDismissRequest: () -> Unit) -> Unit,
mediaRouteDynamicControllerDialog: @Composable () -> Unit,
onDismissRequest: () -> Unit,
) {
val context = LocalContext.current
val params = router.routerParams
var useDynamicGroup = false

if (params != null) {
if (params.isOutputSwitcherEnabled && MediaRouter.isMediaTransferEnabled()) {
if (SystemOutputSwitcherDialogController.showDialog(context)) {
return
}
}

useDynamicGroup = params.dialogType == MediaRouterParams.DIALOG_TYPE_DYNAMIC_GROUP
}

if (router.selectedRoute.isDefaultOrBluetooth) {
if (useDynamicGroup) {
mediaRouteDynamicChooserDialog()
} else {
mediaRouteChooserDialog(onDismissRequest)
}
} else {
if (useDynamicGroup) {
mediaRouteDynamicControllerDialog()
} else {
mediaRouteControllerDialog(onDismissRequest)
}
}
}

@Composable
private fun rememberMediaRouterCallback(
action: () -> Unit,
): MediaRouter.Callback {
return remember {
object : MediaRouter.Callback() {
override fun onRouteAdded(router: MediaRouter, route: RouteInfo) {
action()
}

override fun onRouteRemoved(router: MediaRouter, route: RouteInfo) {
action()
}

override fun onRouteChanged(router: MediaRouter, route: RouteInfo) {
action()
}

override fun onRouteSelected(router: MediaRouter, route: RouteInfo, reason: Int) {
action()
}

override fun onRouteUnselected(router: MediaRouter, route: RouteInfo, reason: Int) {
action()
}

override fun onProviderAdded(router: MediaRouter, provider: MediaRouter.ProviderInfo) {
action()
}

override fun onProviderRemoved(
router: MediaRouter,
provider: MediaRouter.ProviderInfo
) {
action()
}

override fun onProviderChanged(
router: MediaRouter,
provider: MediaRouter.ProviderInfo
) {
action()
}
}
}
}

private fun computeConnectionState(router: MediaRouter): CastConnectionState {
val selectedRoute = router.selectedRoute
val isRemote = !selectedRoute.isDefaultOrBluetooth

return if (isRemote) {
when (selectedRoute.connectionState) {
RouteInfo.CONNECTION_STATE_CONNECTED -> CastConnectionState.Connected
RouteInfo.CONNECTION_STATE_CONNECTING -> CastConnectionState.Connecting
RouteInfo.CONNECTION_STATE_DISCONNECTED -> CastConnectionState.Disconnected
else -> error("Unknown connection state: ${selectedRoute.connectionState}")
}
} else {
CastConnectionState.Disconnected
}
}

@StringRes
private fun computeContentDescriptionRes(connectionState: CastConnectionState): Int {
return when (connectionState) {
CastConnectionState.Connected -> R.string.mr_cast_button_connected
CastConnectionState.Connecting -> R.string.mr_cast_button_connecting
CastConnectionState.Disconnected -> R.string.mr_cast_button_disconnected
when (dialogType) {
DialogType.Chooser -> mediaRouteChooserDialog(viewModel::hideDialog)
DialogType.DynamicChooser -> mediaRouteDynamicChooserDialog()
DialogType.Controller -> mediaRouteControllerDialog(viewModel::hideDialog)
DialogType.DynamicController -> mediaRouteDynamicControllerDialog()
DialogType.None -> Unit
}
}
Loading

0 comments on commit 4add9ee

Please sign in to comment.