• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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