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.animation.accentPrimaryFromScheme 37 import com.android.systemui.media.controls.ui.animation.surfaceFromScheme 38 import com.android.systemui.media.controls.ui.animation.textPrimaryFromScheme 39 import com.android.systemui.media.controls.ui.util.MediaArtworkHelper 40 import com.android.systemui.media.controls.util.MediaUiEventLogger 41 import com.android.systemui.monet.ColorScheme 42 import com.android.systemui.monet.Style 43 import com.android.systemui.res.R 44 import java.util.concurrent.Executor 45 import kotlinx.coroutines.CoroutineDispatcher 46 import kotlinx.coroutines.ExperimentalCoroutinesApi 47 import kotlinx.coroutines.flow.Flow 48 import kotlinx.coroutines.flow.distinctUntilChanged 49 import kotlinx.coroutines.flow.flatMapLatest 50 import kotlinx.coroutines.flow.flowOn 51 import kotlinx.coroutines.flow.map 52 53 /** Models UI state and handles user input for a media control. */ 54 class MediaControlViewModel( 55 @Application private val applicationContext: Context, 56 @Background private val backgroundDispatcher: CoroutineDispatcher, 57 @Background private val backgroundExecutor: Executor, 58 private val interactor: MediaControlInteractor, 59 private val logger: MediaUiEventLogger, 60 ) { 61 62 @OptIn(ExperimentalCoroutinesApi::class) 63 val player: Flow<MediaPlayerViewModel?> = 64 interactor.onAnyMediaConfigurationChange 65 .flatMapLatest { 66 interactor.mediaControl.map { mediaControl -> 67 mediaControl?.let { toViewModel(it) } 68 } 69 } 70 .distinctUntilChanged() 71 .flowOn(backgroundDispatcher) 72 73 private var isPlaying = false 74 private var isAnyButtonClicked = false 75 76 private fun onDismissMediaData( 77 token: Token?, 78 uid: Int, 79 packageName: String, 80 instanceId: InstanceId 81 ) { 82 logger.logLongPressDismiss(uid, packageName, instanceId) 83 interactor.removeMediaControl(token, instanceId, MEDIA_PLAYER_ANIMATION_DELAY) 84 } 85 86 private suspend fun toViewModel(model: MediaControlModel): MediaPlayerViewModel? { 87 val mediaController = model.token?.let { MediaController(applicationContext, it) } 88 val wallpaperColors = 89 MediaArtworkHelper.getWallpaperColor( 90 applicationContext, 91 backgroundDispatcher, 92 model.artwork, 93 TAG 94 ) 95 val scheme = 96 wallpaperColors?.let { ColorScheme(it, true, Style.CONTENT) } 97 ?: MediaArtworkHelper.getColorScheme( 98 applicationContext, 99 model.packageName, 100 TAG, 101 Style.CONTENT 102 ) 103 ?: return null 104 105 val gutsViewModel = toGutsViewModel(model, scheme) 106 107 // Set playing state 108 val wasPlaying = isPlaying 109 isPlaying = 110 mediaController?.playbackState?.let { it.state == PlaybackState.STATE_PLAYING } ?: false 111 112 // Resetting button clicks state. 113 val wasButtonClicked = isAnyButtonClicked 114 isAnyButtonClicked = false 115 116 return MediaPlayerViewModel( 117 contentDescription = { gutsVisible -> 118 if (gutsVisible) { 119 gutsViewModel.gutsText 120 } else { 121 applicationContext.getString( 122 R.string.controls_media_playing_item_description, 123 model.songName, 124 model.artistName, 125 model.appName 126 ) 127 } 128 }, 129 backgroundCover = model.artwork, 130 appIcon = model.appIcon, 131 launcherIcon = getIconFromApp(model.packageName), 132 useGrayColorFilter = model.appIcon == null || model.isResume, 133 artistName = model.artistName ?: "", 134 titleName = model.songName ?: "", 135 isExplicitVisible = model.showExplicit, 136 shouldAddGradient = wallpaperColors != null, 137 colorScheme = scheme, 138 canShowTime = canShowScrubbingTimeViews(model.semanticActionButtons), 139 playTurbulenceNoise = isPlaying && !wasPlaying && wasButtonClicked, 140 useSemanticActions = model.semanticActionButtons != null, 141 actionButtons = toActionViewModels(model), 142 outputSwitcher = toOutputSwitcherViewModel(model), 143 gutsMenu = gutsViewModel, 144 onClicked = { expandable -> 145 model.clickIntent?.let { clickIntent -> 146 logger.logTapContentView(model.uid, model.packageName, model.instanceId) 147 // TODO (b/330897926) log smartspace card reported (SMARTSPACE_CARD_CLICK_EVENT) 148 interactor.startClickIntent(expandable, clickIntent) 149 } 150 }, 151 onLongClicked = { 152 logger.logLongPressOpen(model.uid, model.packageName, model.instanceId) 153 }, 154 onSeek = { 155 logger.logSeek(model.uid, model.packageName, model.instanceId) 156 // TODO (b/330897926) log smartspace card reported (SMARTSPACE_CARD_CLICK_EVENT) 157 }, 158 onBindSeekbar = { seekBarViewModel -> 159 if (model.isResume && model.resumeProgress != null) { 160 seekBarViewModel.updateStaticProgress(model.resumeProgress) 161 } else { 162 backgroundExecutor.execute { 163 seekBarViewModel.updateController(mediaController) 164 } 165 } 166 } 167 ) 168 } 169 170 private fun toOutputSwitcherViewModel(model: MediaControlModel): MediaOutputSwitcherViewModel { 171 val device = model.deviceData 172 val showBroadcastButton = legacyLeAudioSharing() && device?.showBroadcastButton == true 173 174 // TODO(b/233698402): Use the package name instead of app label to avoid the unexpected 175 // result. 176 val isCurrentBroadcastApp = 177 device?.name?.let { 178 TextUtils.equals( 179 it, 180 applicationContext.getString(R.string.broadcasting_description_is_broadcasting) 181 ) 182 } 183 ?: false 184 val useDisabledAlpha = 185 if (showBroadcastButton) { 186 !isCurrentBroadcastApp 187 } else { 188 device?.enabled == false || model.isResume 189 } 190 val deviceString = 191 device?.name 192 ?: if (showBroadcastButton) { 193 applicationContext.getString(R.string.bt_le_audio_broadcast_dialog_unknown_name) 194 } else { 195 applicationContext.getString(R.string.media_seamless_other_device) 196 } 197 return MediaOutputSwitcherViewModel( 198 isTapEnabled = showBroadcastButton || !useDisabledAlpha, 199 deviceString = deviceString, 200 deviceIcon = device?.icon?.let { Icon.Loaded(it, null) } 201 ?: if (showBroadcastButton) { 202 Icon.Resource(R.drawable.settings_input_antenna, null) 203 } else { 204 Icon.Resource(R.drawable.ic_media_home_devices, null) 205 }, 206 isCurrentBroadcastApp = isCurrentBroadcastApp, 207 isIntentValid = device?.intent != null, 208 alpha = 209 if (useDisabledAlpha) { 210 DISABLED_ALPHA 211 } else { 212 1.0f 213 }, 214 isVisible = showBroadcastButton, 215 onClicked = { expandable -> 216 if (showBroadcastButton) { 217 // If the current media app is not broadcasted and users press the outputer 218 // button, we should pop up the broadcast dialog to check do they want to 219 // switch broadcast to the other media app, otherwise we still pop up the 220 // media output dialog. 221 if (!isCurrentBroadcastApp) { 222 logger.logOpenBroadcastDialog( 223 model.uid, 224 model.packageName, 225 model.instanceId 226 ) 227 interactor.startBroadcastDialog( 228 expandable, 229 device?.name.toString(), 230 model.packageName 231 ) 232 } else { 233 logger.logOpenOutputSwitcher(model.uid, model.packageName, model.instanceId) 234 interactor.startMediaOutputDialog( 235 expandable, 236 model.packageName, 237 model.token 238 ) 239 } 240 } else { 241 logger.logOpenOutputSwitcher(model.uid, model.packageName, model.instanceId) 242 device?.intent?.let { interactor.startDeviceIntent(it) } 243 ?: interactor.startMediaOutputDialog( 244 expandable, 245 model.packageName, 246 model.token 247 ) 248 } 249 } 250 ) 251 } 252 253 private fun toGutsViewModel(model: MediaControlModel, scheme: ColorScheme): GutsViewModel { 254 return GutsViewModel( 255 gutsText = 256 if (model.isDismissible) { 257 applicationContext.getString( 258 R.string.controls_media_close_session, 259 model.appName 260 ) 261 } else { 262 applicationContext.getString(R.string.controls_media_active_session) 263 }, 264 textPrimaryColor = textPrimaryFromScheme(scheme), 265 accentPrimaryColor = accentPrimaryFromScheme(scheme), 266 surfaceColor = surfaceFromScheme(scheme), 267 isDismissEnabled = model.isDismissible, 268 onDismissClicked = { 269 onDismissMediaData(model.token, model.uid, model.packageName, model.instanceId) 270 }, 271 cancelTextBackground = 272 if (model.isDismissible) { 273 applicationContext.getDrawable(R.drawable.qs_media_outline_button) 274 } else { 275 applicationContext.getDrawable(R.drawable.qs_media_solid_button) 276 }, 277 onSettingsClicked = { 278 logger.logLongPressSettings(model.uid, model.packageName, model.instanceId) 279 interactor.startSettings() 280 }, 281 ) 282 } 283 284 private fun toActionViewModels(model: MediaControlModel): List<MediaActionViewModel> { 285 val semanticActionButtons = 286 model.semanticActionButtons?.let { mediaButton -> 287 val isScrubbingTimeEnabled = canShowScrubbingTimeViews(mediaButton) 288 SEMANTIC_ACTIONS_ALL.map { buttonId -> 289 toSemanticActionViewModel( 290 model, 291 mediaButton.getActionById(buttonId), 292 buttonId, 293 isScrubbingTimeEnabled 294 ) 295 } 296 } 297 val notifActionButtons = 298 model.notificationActionButtons.mapIndexed { index, mediaAction -> 299 toNotifActionViewModel(model, mediaAction, index) 300 } 301 return semanticActionButtons ?: notifActionButtons 302 } 303 304 private fun toSemanticActionViewModel( 305 model: MediaControlModel, 306 mediaAction: MediaAction?, 307 buttonId: Int, 308 canShowScrubbingTimeViews: Boolean 309 ): MediaActionViewModel { 310 val showInCollapsed = SEMANTIC_ACTIONS_COMPACT.contains(buttonId) 311 val hideWhenScrubbing = SEMANTIC_ACTIONS_HIDE_WHEN_SCRUBBING.contains(buttonId) 312 val shouldHideWhenScrubbing = canShowScrubbingTimeViews && hideWhenScrubbing 313 return MediaActionViewModel( 314 icon = mediaAction?.icon, 315 contentDescription = mediaAction?.contentDescription, 316 background = mediaAction?.background, 317 isVisibleWhenScrubbing = !shouldHideWhenScrubbing, 318 notVisibleValue = 319 if ( 320 (buttonId == R.id.actionPrev && model.semanticActionButtons!!.reservePrev) || 321 (buttonId == R.id.actionNext && model.semanticActionButtons!!.reserveNext) 322 ) { 323 ConstraintSet.INVISIBLE 324 } else { 325 ConstraintSet.GONE 326 }, 327 showInCollapsed = showInCollapsed, 328 rebindId = mediaAction?.rebindId, 329 buttonId = buttonId, 330 isEnabled = mediaAction?.action != null, 331 onClicked = { id -> 332 mediaAction?.action?.let { 333 onButtonClicked(id, model.uid, model.packageName, model.instanceId, it) 334 } 335 }, 336 ) 337 } 338 339 private fun toNotifActionViewModel( 340 model: MediaControlModel, 341 mediaAction: MediaAction, 342 index: Int 343 ): MediaActionViewModel { 344 return MediaActionViewModel( 345 icon = mediaAction.icon, 346 contentDescription = mediaAction.contentDescription, 347 background = mediaAction.background, 348 showInCollapsed = model.actionsToShowInCollapsed.contains(index), 349 rebindId = mediaAction.rebindId, 350 isEnabled = mediaAction.action != null, 351 onClicked = { id -> 352 mediaAction.action?.let { 353 onButtonClicked(id, model.uid, model.packageName, model.instanceId, it) 354 } 355 }, 356 ) 357 } 358 359 private fun onButtonClicked( 360 id: Int, 361 uid: Int, 362 packageName: String, 363 instanceId: InstanceId, 364 action: Runnable 365 ) { 366 logger.logTapAction(id, uid, packageName, instanceId) 367 // TODO (b/330897926) log smartspace card reported (SMARTSPACE_CARD_CLICK_EVENT) 368 isAnyButtonClicked = true 369 action.run() 370 } 371 372 private fun getIconFromApp(packageName: String): Icon { 373 return try { 374 Icon.Loaded(applicationContext.packageManager.getApplicationIcon(packageName), null) 375 } catch (e: PackageManager.NameNotFoundException) { 376 Log.w(TAG, "Cannot find icon for package $packageName", e) 377 Icon.Resource(R.drawable.ic_music_note, null) 378 } 379 } 380 381 private fun canShowScrubbingTimeViews(semanticActions: MediaButton?): Boolean { 382 // The scrubbing time views replace the SEMANTIC_ACTIONS_HIDE_WHEN_SCRUBBING action views, 383 // so we should only allow scrubbing times to be shown if those action views are present. 384 return semanticActions?.let { 385 SEMANTIC_ACTIONS_HIDE_WHEN_SCRUBBING.stream().allMatch { id: Int -> 386 semanticActions.getActionById(id) != null 387 } 388 } 389 ?: false 390 } 391 392 companion object { 393 private const val TAG = "MediaControlViewModel" 394 private const val MEDIA_PLAYER_ANIMATION_DELAY = 334L 395 private const val DISABLED_ALPHA = 0.38f 396 397 /** Buttons to show in small player when using semantic actions */ 398 val SEMANTIC_ACTIONS_COMPACT = 399 listOf(R.id.actionPlayPause, R.id.actionPrev, R.id.actionNext) 400 401 /** 402 * Buttons that should get hidden when we are scrubbing (they will be replaced with the 403 * views showing scrubbing time) 404 */ 405 val SEMANTIC_ACTIONS_HIDE_WHEN_SCRUBBING = listOf(R.id.actionPrev, R.id.actionNext) 406 407 /** Buttons to show in player when using semantic actions. */ 408 val SEMANTIC_ACTIONS_ALL = 409 listOf( 410 R.id.actionPlayPause, 411 R.id.actionPrev, 412 R.id.actionNext, 413 R.id.action0, 414 R.id.action1 415 ) 416 417 const val TURBULENCE_NOISE_PLAY_MS_DURATION = 7500L 418 const val MEDIA_PLAYER_SCRIM_START_ALPHA = 0.25f 419 const val MEDIA_PLAYER_SCRIM_END_ALPHA = 1.0f 420 } 421 } 422