1 /*
2  * Copyright (C) 2022 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.view
18 
19 import android.graphics.Rect
20 import android.util.ArraySet
21 import android.view.View
22 import android.view.View.OnAttachStateChangeListener
23 import com.android.systemui.media.controls.domain.pipeline.MediaDataManager
24 import com.android.systemui.media.controls.shared.model.MediaData
25 import com.android.systemui.media.controls.shared.model.SmartspaceMediaData
26 import com.android.systemui.media.controls.ui.controller.MediaCarouselController
27 import com.android.systemui.media.controls.ui.controller.MediaHierarchyManager
28 import com.android.systemui.media.controls.ui.controller.MediaHostStatesManager
29 import com.android.systemui.media.controls.ui.controller.MediaLocation
30 import com.android.systemui.util.animation.DisappearParameters
31 import com.android.systemui.util.animation.MeasurementInput
32 import com.android.systemui.util.animation.MeasurementOutput
33 import com.android.systemui.util.animation.UniqueObjectHostView
34 import java.util.Objects
35 import javax.inject.Inject
36 
37 class MediaHost(
38     private val state: MediaHostStateHolder,
39     private val mediaHierarchyManager: MediaHierarchyManager,
40     private val mediaDataManager: MediaDataManager,
41     private val mediaHostStatesManager: MediaHostStatesManager,
42     private val mediaCarouselController: MediaCarouselController,
<lambda>null43 ) : MediaHostState by state {
44     lateinit var hostView: UniqueObjectHostView
45     var location: Int = -1
46         private set
47     private var visibleChangedListeners: ArraySet<(Boolean) -> Unit> = ArraySet()
48 
49     private val tmpLocationOnScreen: IntArray = intArrayOf(0, 0)
50 
51     private var inited: Boolean = false
52 
53     /** Are we listening to media data changes? */
54     private var listeningToMediaData = false
55 
56     /** Get the current bounds on the screen. This makes sure the state is fresh and up to date */
57     val currentBounds: Rect = Rect()
58         get() {
59             hostView.getLocationOnScreen(tmpLocationOnScreen)
60             var left = tmpLocationOnScreen[0] + hostView.paddingLeft
61             var top = tmpLocationOnScreen[1] + hostView.paddingTop
62             var right = tmpLocationOnScreen[0] + hostView.width - hostView.paddingRight
63             var bottom = tmpLocationOnScreen[1] + hostView.height - hostView.paddingBottom
64             // Handle cases when the width or height is 0 but it has padding. In those cases
65             // the above could return negative widths, which is wrong
66             if (right < left) {
67                 left = 0
68                 right = 0
69             }
70             if (bottom < top) {
71                 bottom = 0
72                 top = 0
73             }
74             field.set(left, top, right, bottom)
75             return field
76         }
77 
78     /**
79      * Set the clipping that this host should use, based on its parent's bounds.
80      *
81      * Use [Rect.set].
82      */
83     val currentClipping = Rect()
84 
85     private val listener =
86         object : MediaDataManager.Listener {
87             override fun onMediaDataLoaded(
88                 key: String,
89                 oldKey: String?,
90                 data: MediaData,
91                 immediately: Boolean,
92                 receivedSmartspaceCardLatency: Int,
93                 isSsReactivated: Boolean
94             ) {
95                 if (immediately) {
96                     updateViewVisibility()
97                 }
98             }
99 
100             override fun onSmartspaceMediaDataLoaded(
101                 key: String,
102                 data: SmartspaceMediaData,
103                 shouldPrioritize: Boolean
104             ) {
105                 updateViewVisibility()
106             }
107 
108             override fun onMediaDataRemoved(key: String, userInitiated: Boolean) {
109                 updateViewVisibility()
110             }
111 
112             override fun onSmartspaceMediaDataRemoved(key: String, immediately: Boolean) {
113                 if (immediately) {
114                     updateViewVisibility()
115                 }
116             }
117         }
118 
119     fun addVisibilityChangeListener(listener: (Boolean) -> Unit) {
120         visibleChangedListeners.add(listener)
121     }
122 
123     fun removeVisibilityChangeListener(listener: (Boolean) -> Unit) {
124         visibleChangedListeners.remove(listener)
125     }
126 
127     /**
128      * Initialize this MediaObject and create a host view. All state should already be set on this
129      * host before calling this method in order to avoid unnecessary state changes which lead to
130      * remeasurings later on.
131      *
132      * @param location the location this host name has. Used to identify the host during
133      *
134      * ```
135      *                 transitions.
136      * ```
137      */
138     fun init(@MediaLocation location: Int) {
139         if (inited) {
140             return
141         }
142         inited = true
143 
144         this.location = location
145         hostView = mediaHierarchyManager.register(this)
146         // Listen by default, as the host might not be attached by our clients, until
147         // they get a visibility change. We still want to stay up to date in that case!
148         setListeningToMediaData(true)
149         hostView.addOnAttachStateChangeListener(
150             object : OnAttachStateChangeListener {
151                 override fun onViewAttachedToWindow(v: View) {
152                     setListeningToMediaData(true)
153                     updateViewVisibility()
154                 }
155 
156                 override fun onViewDetachedFromWindow(v: View) {
157                     setListeningToMediaData(false)
158                 }
159             }
160         )
161 
162         // Listen to measurement updates and update our state with it
163         hostView.measurementManager =
164             object : UniqueObjectHostView.MeasurementManager {
165                 override fun onMeasure(input: MeasurementInput): MeasurementOutput {
166                     // Modify the measurement to exactly match the dimensions
167                     if (
168                         View.MeasureSpec.getMode(input.widthMeasureSpec) == View.MeasureSpec.AT_MOST
169                     ) {
170                         input.widthMeasureSpec =
171                             View.MeasureSpec.makeMeasureSpec(
172                                 View.MeasureSpec.getSize(input.widthMeasureSpec),
173                                 View.MeasureSpec.EXACTLY
174                             )
175                     }
176                     // This will trigger a state change that ensures that we now have a state
177                     // available
178                     state.measurementInput = input
179                     return mediaHostStatesManager.updateCarouselDimensions(location, state)
180                 }
181             }
182 
183         // Whenever the state changes, let our state manager know
184         state.changedListener = { mediaHostStatesManager.updateHostState(location, state) }
185 
186         updateViewVisibility()
187     }
188 
189     private fun setListeningToMediaData(listen: Boolean) {
190         if (listen != listeningToMediaData) {
191             listeningToMediaData = listen
192             if (listen) {
193                 mediaDataManager.addListener(listener)
194             } else {
195                 mediaDataManager.removeListener(listener)
196             }
197         }
198     }
199 
200     /**
201      * Updates this host's state based on the current media data's status, and invokes listeners if
202      * the visibility has changed
203      */
204     fun updateViewVisibility() {
205         state.visible =
206             if (mediaCarouselController.isLockedAndHidden()) {
207                 false
208             } else if (showsOnlyActiveMedia) {
209                 mediaDataManager.hasActiveMediaOrRecommendation()
210             } else {
211                 mediaDataManager.hasAnyMediaOrRecommendation()
212             }
213         val newVisibility = if (visible) View.VISIBLE else View.GONE
214         if (newVisibility != hostView.visibility) {
215             hostView.visibility = newVisibility
216             visibleChangedListeners.forEach { it.invoke(visible) }
217         }
218     }
219 
220     class MediaHostStateHolder @Inject constructor() : MediaHostState {
221         override var measurementInput: MeasurementInput? = null
222             set(value) {
223                 if (value?.equals(field) != true) {
224                     field = value
225                     changedListener?.invoke()
226                 }
227             }
228 
229         override var expansion: Float = 0.0f
230             set(value) {
231                 if (!value.equals(field)) {
232                     field = value
233                     changedListener?.invoke()
234                 }
235             }
236 
237         override var expandedMatchesParentHeight: Boolean = false
238             set(value) {
239                 if (value != field) {
240                     field = value
241                     changedListener?.invoke()
242                 }
243             }
244 
245         override var squishFraction: Float = 1.0f
246             set(value) {
247                 if (!value.equals(field)) {
248                     field = value
249                     changedListener?.invoke()
250                 }
251             }
252 
253         override var showsOnlyActiveMedia: Boolean = false
254             set(value) {
255                 if (!value.equals(field)) {
256                     field = value
257                     changedListener?.invoke()
258                 }
259             }
260 
261         override var visible: Boolean = true
262             set(value) {
263                 if (field == value) {
264                     return
265                 }
266                 field = value
267                 changedListener?.invoke()
268             }
269 
270         override var falsingProtectionNeeded: Boolean = false
271             set(value) {
272                 if (field == value) {
273                     return
274                 }
275                 field = value
276                 changedListener?.invoke()
277             }
278 
279         override var disappearParameters: DisappearParameters = DisappearParameters()
280             set(value) {
281                 val newHash = value.hashCode()
282                 if (lastDisappearHash.equals(newHash)) {
283                     return
284                 }
285                 field = value
286                 lastDisappearHash = newHash
287                 changedListener?.invoke()
288             }
289 
290         private var lastDisappearHash = disappearParameters.hashCode()
291 
292         /** A listener for all changes. This won't be copied over when invoking [copy] */
293         var changedListener: (() -> Unit)? = null
294 
295         /** Get a copy of this state. This won't copy any listeners it may have set */
296         override fun copy(): MediaHostState {
297             val mediaHostState = MediaHostStateHolder()
298             mediaHostState.expansion = expansion
299             mediaHostState.expandedMatchesParentHeight = expandedMatchesParentHeight
300             mediaHostState.squishFraction = squishFraction
301             mediaHostState.showsOnlyActiveMedia = showsOnlyActiveMedia
302             mediaHostState.measurementInput = measurementInput?.copy()
303             mediaHostState.visible = visible
304             mediaHostState.disappearParameters = disappearParameters.deepCopy()
305             mediaHostState.falsingProtectionNeeded = falsingProtectionNeeded
306             return mediaHostState
307         }
308 
309         override fun equals(other: Any?): Boolean {
310             if (!(other is MediaHostState)) {
311                 return false
312             }
313             if (!Objects.equals(measurementInput, other.measurementInput)) {
314                 return false
315             }
316             if (expansion != other.expansion) {
317                 return false
318             }
319             if (squishFraction != other.squishFraction) {
320                 return false
321             }
322             if (showsOnlyActiveMedia != other.showsOnlyActiveMedia) {
323                 return false
324             }
325             if (visible != other.visible) {
326                 return false
327             }
328             if (falsingProtectionNeeded != other.falsingProtectionNeeded) {
329                 return false
330             }
331             if (!disappearParameters.equals(other.disappearParameters)) {
332                 return false
333             }
334             return true
335         }
336 
337         override fun hashCode(): Int {
338             var result = measurementInput?.hashCode() ?: 0
339             result = 31 * result + expansion.hashCode()
340             result = 31 * result + squishFraction.hashCode()
341             result = 31 * result + falsingProtectionNeeded.hashCode()
342             result = 31 * result + showsOnlyActiveMedia.hashCode()
343             result = 31 * result + if (visible) 1 else 2
344             result = 31 * result + disappearParameters.hashCode()
345             return result
346         }
347     }
348 }
349 
350 /**
351  * A description of a media host state that describes the behavior whenever the media carousel is
352  * hosted. The HostState notifies the media players of changes to their properties, who in turn will
353  * create view states from it. When adding a new property to this, make sure to update the listener
354  * and notify them about the changes. In case you need to have a different rendering based on the
355  * state, you can add a new constraintState to the [MediaViewController]. Otherwise, similar host
356  * states will resolve to the same viewstate, a behavior that is described in [CacheKey]. Make sure
357  * to only update that key if the underlying view needs to have a different measurement.
358  */
359 interface MediaHostState {
360 
361     companion object {
362         const val EXPANDED: Float = 1.0f
363         const val COLLAPSED: Float = 0.0f
364     }
365 
366     /**
367      * The last measurement input that this state was measured with. Infers width and height of the
368      * players.
369      */
370     var measurementInput: MeasurementInput?
371 
372     /**
373      * The expansion of the player, [COLLAPSED] for fully collapsed (up to 3 actions), [EXPANDED]
374      * for fully expanded (up to 5 actions).
375      */
376     var expansion: Float
377 
378     /**
379      * If true, the [EXPANDED] layout should stretch to match the height of its parent container,
380      * rather than having a fixed height.
381      */
382     var expandedMatchesParentHeight: Boolean
383 
384     /** Fraction of the height animation. */
385     var squishFraction: Float
386 
387     /** Is this host only showing active media or is it showing all of them including resumption? */
388     var showsOnlyActiveMedia: Boolean
389 
390     /** If the view should be VISIBLE or GONE. */
391     val visible: Boolean
392 
393     /** Does this host need any falsing protection? */
394     var falsingProtectionNeeded: Boolean
395 
396     /**
397      * The parameters how the view disappears from this location when going to a host that's not
398      * visible. If modified, make sure to set this value again on the host to ensure the values are
399      * propagated
400      */
401     var disappearParameters: DisappearParameters
402 
403     /** Get a copy of this view state, deepcopying all appropriate members */
copynull404     fun copy(): MediaHostState
405 }
406