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