1 /* <lambda>null2 * Copyright (C) 2024 The Android Open Source Project 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); 5 * you may not use this file except in compliance with the License. 6 * You may obtain a copy of the License at 7 * 8 * http://www.apache.org/licenses/LICENSE-2.0 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License. 15 */ 16 17 package com.android.systemui.media.controls.ui.viewmodel 18 19 import android.content.Context 20 import android.content.pm.PackageManager 21 import android.media.session.MediaController 22 import android.media.session.MediaSession.Token 23 import android.media.session.PlaybackState 24 import android.text.TextUtils 25 import android.util.Log 26 import androidx.constraintlayout.widget.ConstraintSet 27 import com.android.internal.logging.InstanceId 28 import com.android.settingslib.flags.Flags.legacyLeAudioSharing 29 import com.android.systemui.common.shared.model.Icon 30 import com.android.systemui.dagger.qualifiers.Application 31 import com.android.systemui.dagger.qualifiers.Background 32 import com.android.systemui.media.controls.domain.pipeline.interactor.MediaControlInteractor 33 import com.android.systemui.media.controls.shared.model.MediaAction 34 import com.android.systemui.media.controls.shared.model.MediaButton 35 import com.android.systemui.media.controls.shared.model.MediaControlModel 36 import com.android.systemui.media.controls.ui.controller.MediaHierarchyManager 37 import com.android.systemui.media.controls.ui.controller.MediaLocation 38 import com.android.systemui.media.controls.util.MediaUiEventLogger 39 import com.android.systemui.res.R 40 import java.util.concurrent.Executor 41 import kotlinx.coroutines.CoroutineDispatcher 42 import kotlinx.coroutines.flow.Flow 43 import kotlinx.coroutines.flow.distinctUntilChanged 44 import kotlinx.coroutines.flow.flowOn 45 import kotlinx.coroutines.flow.map 46 47 /** Models UI state and handles user input for a media control. */ 48 data class MediaControlViewModel( 49 @Application private val applicationContext: Context, 50 @Background private val backgroundDispatcher: CoroutineDispatcher, 51 @Background private val backgroundExecutor: Executor, 52 private val interactor: MediaControlInteractor, 53 private val logger: MediaUiEventLogger, 54 val instanceId: InstanceId, 55 val onAdded: (MediaControlViewModel) -> Unit, 56 val onRemoved: (Boolean) -> Unit, 57 val onUpdated: (MediaControlViewModel) -> Unit, 58 val updateTime: Long = 0, 59 ) { 60 val player: Flow<MediaPlayerViewModel?> = 61 interactor.mediaControl 62 .map { mediaControl -> mediaControl?.let { toViewModel(it) } } 63 .distinctUntilChanged { old, new -> 64 (new == null && old == null) || new?.contentEquals(old) ?: false 65 } 66 .flowOn(backgroundDispatcher) 67 68 private var isPlaying = false 69 private var isAnyButtonClicked = false 70 @MediaLocation private var location = MediaHierarchyManager.LOCATION_UNKNOWN 71 private var playerViewModel: MediaPlayerViewModel? = null 72 private var allowPlayerUpdate: Boolean = false 73 74 fun setPlayer(viewModel: MediaPlayerViewModel): Boolean { 75 val tempViewModel = playerViewModel 76 playerViewModel = viewModel 77 return allowPlayerUpdate || !(tempViewModel?.contentEquals(viewModel) ?: false) 78 } 79 80 fun onMediaConfigChanged() { 81 allowPlayerUpdate = true 82 } 83 84 fun onMediaControlIsBound(artistName: CharSequence, titleName: CharSequence) { 85 interactor.logMediaControlIsBound(artistName, titleName) 86 allowPlayerUpdate = false 87 } 88 89 private fun onDismissMediaData( 90 token: Token?, 91 uid: Int, 92 packageName: String, 93 instanceId: InstanceId, 94 ) { 95 logger.logLongPressDismiss(uid, packageName, instanceId) 96 interactor.removeMediaControl(token, instanceId, MEDIA_PLAYER_ANIMATION_DELAY) 97 } 98 99 private fun toViewModel(model: MediaControlModel): MediaPlayerViewModel { 100 val mediaController = model.token?.let { MediaController(applicationContext, it) } 101 val gutsViewModel = toGutsViewModel(model) 102 103 // Set playing state 104 val wasPlaying = isPlaying 105 isPlaying = 106 mediaController?.playbackState?.let { it.state == PlaybackState.STATE_PLAYING } ?: false 107 108 // Resetting button clicks state. 109 val wasButtonClicked = isAnyButtonClicked 110 isAnyButtonClicked = false 111 112 return MediaPlayerViewModel( 113 contentDescription = { gutsVisible -> 114 if (gutsVisible) { 115 gutsViewModel.gutsText 116 } else { 117 applicationContext.getString( 118 R.string.controls_media_playing_item_description, 119 model.songName, 120 model.artistName, 121 model.appName, 122 ) 123 } 124 }, 125 backgroundCover = model.artwork, 126 appIcon = model.appIcon, 127 launcherIcon = getIconFromApp(model.packageName), 128 useGrayColorFilter = model.appIcon == null || model.isResume, 129 artistName = model.artistName ?: "", 130 titleName = model.songName ?: "", 131 isExplicitVisible = model.showExplicit, 132 canShowTime = canShowScrubbingTimeViews(model.semanticActionButtons), 133 playTurbulenceNoise = isPlaying && !wasPlaying && wasButtonClicked, 134 useSemanticActions = model.semanticActionButtons != null, 135 actionButtons = toActionViewModels(model), 136 outputSwitcher = toOutputSwitcherViewModel(model), 137 gutsMenu = gutsViewModel, 138 onClicked = { expandable -> 139 model.clickIntent?.let { clickIntent -> 140 logger.logTapContentView(model.uid, model.packageName, model.instanceId) 141 interactor.startClickIntent(expandable, clickIntent) 142 } 143 }, 144 onLongClicked = { 145 logger.logLongPressOpen(model.uid, model.packageName, model.instanceId) 146 }, 147 onSeek = { logger.logSeek(model.uid, model.packageName, model.instanceId) }, 148 onBindSeekbar = { seekBarViewModel -> 149 if (model.isResume && model.resumeProgress != null) { 150 seekBarViewModel.updateStaticProgress(model.resumeProgress) 151 } else { 152 backgroundExecutor.execute { 153 seekBarViewModel.updateController(mediaController) 154 } 155 } 156 }, 157 onLocationChanged = { location = it }, 158 ) 159 } 160 161 private fun toOutputSwitcherViewModel(model: MediaControlModel): MediaOutputSwitcherViewModel { 162 val device = model.deviceData 163 val showBroadcastButton = legacyLeAudioSharing() && device?.showBroadcastButton == true 164 165 // TODO(b/233698402): Use the package name instead of app label to avoid the unexpected 166 // result. 167 val isCurrentBroadcastApp = 168 device?.name?.let { 169 TextUtils.equals( 170 it, 171 applicationContext.getString(R.string.broadcasting_description_is_broadcasting), 172 ) 173 } ?: false 174 val useDisabledAlpha = 175 if (showBroadcastButton) { 176 !isCurrentBroadcastApp 177 } else { 178 device?.enabled == false || model.isResume 179 } 180 val deviceString = 181 device?.name 182 ?: if (showBroadcastButton) { 183 applicationContext.getString(R.string.bt_le_audio_broadcast_dialog_unknown_name) 184 } else { 185 applicationContext.getString(R.string.media_seamless_other_device) 186 } 187 return MediaOutputSwitcherViewModel( 188 isTapEnabled = showBroadcastButton || !useDisabledAlpha, 189 deviceString = deviceString, 190 deviceIcon = 191 device?.icon?.let { Icon.Loaded(it, null) } 192 ?: if (showBroadcastButton) { 193 Icon.Resource(R.drawable.settings_input_antenna, null) 194 } else { 195 Icon.Resource(R.drawable.ic_media_home_devices, null) 196 }, 197 isCurrentBroadcastApp = isCurrentBroadcastApp, 198 isIntentValid = device?.intent != null, 199 alpha = 200 if (useDisabledAlpha) { 201 DISABLED_ALPHA 202 } else { 203 1.0f 204 }, 205 isVisible = showBroadcastButton, 206 onClicked = { expandable -> 207 if (showBroadcastButton) { 208 // If the current media app is not broadcasted and users press the outputer 209 // button, we should pop up the broadcast dialog to check do they want to 210 // switch broadcast to the other media app, otherwise we still pop up the 211 // media output dialog. 212 if (!isCurrentBroadcastApp) { 213 logger.logOpenBroadcastDialog( 214 model.uid, 215 model.packageName, 216 model.instanceId, 217 ) 218 interactor.startBroadcastDialog( 219 expandable, 220 device?.name.toString(), 221 model.packageName, 222 ) 223 } else { 224 logger.logOpenOutputSwitcher(model.uid, model.packageName, model.instanceId) 225 interactor.startMediaOutputDialog( 226 expandable, 227 model.packageName, 228 model.token, 229 ) 230 } 231 } else { 232 logger.logOpenOutputSwitcher(model.uid, model.packageName, model.instanceId) 233 device?.intent?.let { interactor.startDeviceIntent(it) } 234 ?: interactor.startMediaOutputDialog( 235 expandable, 236 model.packageName, 237 model.token, 238 ) 239 } 240 }, 241 ) 242 } 243 244 private fun toGutsViewModel(model: MediaControlModel): GutsViewModel { 245 return GutsViewModel( 246 gutsText = 247 if (model.isDismissible) { 248 applicationContext.getString( 249 R.string.controls_media_close_session, 250 model.appName, 251 ) 252 } else { 253 applicationContext.getString(R.string.controls_media_active_session) 254 }, 255 isDismissEnabled = model.isDismissible, 256 onDismissClicked = { 257 onDismissMediaData(model.token, model.uid, model.packageName, model.instanceId) 258 }, 259 cancelTextBackground = 260 if (model.isDismissible) { 261 applicationContext.getDrawable(R.drawable.qs_media_outline_button) 262 } else { 263 applicationContext.getDrawable(R.drawable.qs_media_solid_button) 264 }, 265 onSettingsClicked = { 266 logger.logLongPressSettings(model.uid, model.packageName, model.instanceId) 267 interactor.startSettings() 268 }, 269 ) 270 } 271 272 private fun toActionViewModels(model: MediaControlModel): List<MediaActionViewModel> { 273 val semanticActionButtons = 274 model.semanticActionButtons?.let { mediaButton -> 275 val isScrubbingTimeEnabled = canShowScrubbingTimeViews(mediaButton) 276 SEMANTIC_ACTIONS_ALL.map { buttonId -> 277 toSemanticActionViewModel( 278 model, 279 mediaButton.getActionById(buttonId), 280 buttonId, 281 isScrubbingTimeEnabled, 282 ) 283 } 284 } 285 val notifActionButtons = 286 model.notificationActionButtons.mapIndexed { index, mediaAction -> 287 toNotifActionViewModel(model, mediaAction, index) 288 } 289 return semanticActionButtons ?: notifActionButtons 290 } 291 292 private fun toSemanticActionViewModel( 293 model: MediaControlModel, 294 mediaAction: MediaAction?, 295 buttonId: Int, 296 canShowScrubbingTimeViews: Boolean, 297 ): MediaActionViewModel { 298 val showInCollapsed = SEMANTIC_ACTIONS_COMPACT.contains(buttonId) 299 val hideWhenScrubbing = SEMANTIC_ACTIONS_HIDE_WHEN_SCRUBBING.contains(buttonId) 300 val shouldHideWhenScrubbing = canShowScrubbingTimeViews && hideWhenScrubbing 301 return MediaActionViewModel( 302 icon = mediaAction?.icon, 303 contentDescription = mediaAction?.contentDescription, 304 background = mediaAction?.background, 305 isVisibleWhenScrubbing = !shouldHideWhenScrubbing, 306 notVisibleValue = 307 if ( 308 !shouldHideWhenScrubbing && 309 ((buttonId == R.id.actionPrev && 310 model.semanticActionButtons!!.reservePrev) || 311 (buttonId == R.id.actionNext && 312 model.semanticActionButtons!!.reserveNext)) 313 ) { 314 ConstraintSet.INVISIBLE 315 } else { 316 ConstraintSet.GONE 317 }, 318 showInCollapsed = showInCollapsed, 319 rebindId = mediaAction?.rebindId, 320 buttonId = buttonId, 321 isEnabled = mediaAction?.action != null, 322 onClicked = { id -> 323 mediaAction?.action?.let { 324 onButtonClicked(id, model.uid, model.packageName, model.instanceId, it) 325 } 326 }, 327 ) 328 } 329 330 private fun toNotifActionViewModel( 331 model: MediaControlModel, 332 mediaAction: MediaAction, 333 index: Int, 334 ): MediaActionViewModel { 335 return MediaActionViewModel( 336 icon = mediaAction.icon, 337 contentDescription = mediaAction.contentDescription, 338 background = mediaAction.background, 339 showInCollapsed = model.actionsToShowInCollapsed.contains(index), 340 rebindId = mediaAction.rebindId, 341 isEnabled = mediaAction.action != null, 342 onClicked = { id -> 343 mediaAction.action?.let { 344 onButtonClicked(id, model.uid, model.packageName, model.instanceId, it) 345 } 346 }, 347 ) 348 } 349 350 private fun onButtonClicked( 351 id: Int, 352 uid: Int, 353 packageName: String, 354 instanceId: InstanceId, 355 action: Runnable, 356 ) { 357 logger.logTapAction(id, uid, packageName, instanceId) 358 isAnyButtonClicked = true 359 action.run() 360 } 361 362 private fun getIconFromApp(packageName: String): Icon { 363 return try { 364 Icon.Loaded(applicationContext.packageManager.getApplicationIcon(packageName), null) 365 } catch (e: PackageManager.NameNotFoundException) { 366 Log.w(TAG, "Cannot find icon for package $packageName", e) 367 Icon.Resource(R.drawable.ic_music_note, null) 368 } 369 } 370 371 private fun canShowScrubbingTimeViews(semanticActions: MediaButton?): Boolean { 372 // The scrubbing time views replace the SEMANTIC_ACTIONS_HIDE_WHEN_SCRUBBING action views, 373 // so we should only allow scrubbing times to be shown if those action views are present. 374 return semanticActions?.let { 375 SEMANTIC_ACTIONS_HIDE_WHEN_SCRUBBING.stream().allMatch { id: Int -> 376 semanticActions.getActionById(id) != null || 377 (id == R.id.actionPrev && semanticActions.reservePrev || 378 id == R.id.actionNext && semanticActions.reserveNext) 379 } 380 } ?: false 381 } 382 383 companion object { 384 private const val TAG = "MediaControlViewModel" 385 private const val MEDIA_PLAYER_ANIMATION_DELAY = 334L 386 private const val DISABLED_ALPHA = 0.38f 387 388 /** Buttons to show in small player when using semantic actions */ 389 val SEMANTIC_ACTIONS_COMPACT = 390 listOf(R.id.actionPlayPause, R.id.actionPrev, R.id.actionNext) 391 392 /** 393 * Buttons that should get hidden when we are scrubbing (they will be replaced with the 394 * views showing scrubbing time) 395 */ 396 val SEMANTIC_ACTIONS_HIDE_WHEN_SCRUBBING = listOf(R.id.actionPrev, R.id.actionNext) 397 398 /** Buttons to show in player when using semantic actions. */ 399 val SEMANTIC_ACTIONS_ALL = 400 listOf( 401 R.id.actionPlayPause, 402 R.id.actionPrev, 403 R.id.actionNext, 404 R.id.action0, 405 R.id.action1, 406 ) 407 408 const val TURBULENCE_NOISE_PLAY_MS_DURATION = 7500L 409 @Deprecated("Remove with media_controls_a11y_colors flag") 410 const val MEDIA_PLAYER_SCRIM_START_ALPHA_LEGACY = 0.25f 411 @Deprecated("Remove with media_controls_a11y_colors flag") 412 const val MEDIA_PLAYER_SCRIM_END_ALPHA_LEGACY = 1.0f 413 const val MEDIA_PLAYER_SCRIM_START_ALPHA = 0.65f 414 const val MEDIA_PLAYER_SCRIM_END_ALPHA = 0.75f 415 } 416 } 417