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