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