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 val oldState = state.visible
212 state.visible =
213 if (mediaCarouselController.isLockedAndHidden()) {
214 false
215 } else if (showsOnlyActiveMedia) {
216 mediaDataManager.hasActiveMediaOrRecommendation()
217 } else {
218 mediaDataManager.hasAnyMediaOrRecommendation()
219 }
220 val newVisibility = if (visible) View.VISIBLE else View.GONE
221 if (oldState != state.visible || newVisibility != hostView.visibility) {
222 hostView.visibility = newVisibility
223 debugLogger.logMediaHostVisibility(location, visible, oldState)
224 visibleChangedListeners.forEach { it.invoke(visible) }
225 }
226 }
227
228 class MediaHostStateHolder @Inject constructor() : MediaHostState {
229 override var measurementInput: MeasurementInput? = null
230 set(value) {
231 if (value?.equals(field) != true) {
232 field = value
233 changedListener?.invoke()
234 }
235 }
236
237 override var expansion: Float = 0.0f
238 set(value) {
239 if (!value.equals(field)) {
240 field = value
241 changedListener?.invoke()
242 }
243 }
244
245 override var expandedMatchesParentHeight: Boolean = false
246 set(value) {
247 if (value != field) {
248 field = value
249 changedListener?.invoke()
250 }
251 }
252
253 override var squishFraction: Float = 1.0f
254 set(value) {
255 if (!value.equals(field)) {
256 field = value
257 changedListener?.invoke()
258 }
259 }
260
261 override var showsOnlyActiveMedia: Boolean = false
262 set(value) {
263 if (!value.equals(field)) {
264 field = value
265 changedListener?.invoke()
266 }
267 }
268
269 override var visible: Boolean = true
270 set(value) {
271 if (field == value) {
272 return
273 }
274 field = value
275 changedListener?.invoke()
276 }
277
278 override var falsingProtectionNeeded: Boolean = false
279 set(value) {
280 if (field == value) {
281 return
282 }
283 field = value
284 changedListener?.invoke()
285 }
286
287 override var disappearParameters: DisappearParameters = DisappearParameters()
288 set(value) {
289 val newHash = value.hashCode()
290 if (lastDisappearHash.equals(newHash)) {
291 return
292 }
293 field = value
294 lastDisappearHash = newHash
295 changedListener?.invoke()
296 }
297
298 override var disableScrolling: Boolean = false
299 set(value) {
300 if (field == value) {
301 return
302 }
303 field = value
304 changedListener?.invoke()
305 }
306
307 private var lastDisappearHash = disappearParameters.hashCode()
308
309 /** A listener for all changes. This won't be copied over when invoking [copy] */
310 var changedListener: (() -> Unit)? = null
311
312 /** Get a copy of this state. This won't copy any listeners it may have set */
313 override fun copy(): MediaHostState {
314 val mediaHostState = MediaHostStateHolder()
315 mediaHostState.expansion = expansion
316 mediaHostState.expandedMatchesParentHeight = expandedMatchesParentHeight
317 mediaHostState.squishFraction = squishFraction
318 mediaHostState.showsOnlyActiveMedia = showsOnlyActiveMedia
319 mediaHostState.measurementInput = measurementInput?.copy()
320 mediaHostState.visible = visible
321 mediaHostState.disappearParameters = disappearParameters.deepCopy()
322 mediaHostState.falsingProtectionNeeded = falsingProtectionNeeded
323 mediaHostState.disableScrolling = disableScrolling
324 return mediaHostState
325 }
326
327 override fun equals(other: Any?): Boolean {
328 if (!(other is MediaHostState)) {
329 return false
330 }
331 if (!Objects.equals(measurementInput, other.measurementInput)) {
332 return false
333 }
334 if (expansion != other.expansion) {
335 return false
336 }
337 if (squishFraction != other.squishFraction) {
338 return false
339 }
340 if (showsOnlyActiveMedia != other.showsOnlyActiveMedia) {
341 return false
342 }
343 if (visible != other.visible) {
344 return false
345 }
346 if (falsingProtectionNeeded != other.falsingProtectionNeeded) {
347 return false
348 }
349 if (!disappearParameters.equals(other.disappearParameters)) {
350 return false
351 }
352 if (disableScrolling != other.disableScrolling) {
353 return false
354 }
355 return true
356 }
357
358 override fun hashCode(): Int {
359 var result = measurementInput?.hashCode() ?: 0
360 result = 31 * result + expansion.hashCode()
361 result = 31 * result + squishFraction.hashCode()
362 result = 31 * result + falsingProtectionNeeded.hashCode()
363 result = 31 * result + showsOnlyActiveMedia.hashCode()
364 result = 31 * result + if (visible) 1 else 2
365 result = 31 * result + disappearParameters.hashCode()
366 result = 31 * result + disableScrolling.hashCode()
367 return result
368 }
369 }
370 }
371
372 /**
373 * A description of a media host state that describes the behavior whenever the media carousel is
374 * hosted. The HostState notifies the media players of changes to their properties, who in turn will
375 * create view states from it. When adding a new property to this, make sure to update the listener
376 * and notify them about the changes. In case you need to have a different rendering based on the
377 * state, you can add a new constraintState to the [MediaViewController]. Otherwise, similar host
378 * states will resolve to the same viewstate, a behavior that is described in [CacheKey]. Make sure
379 * to only update that key if the underlying view needs to have a different measurement.
380 */
381 interface MediaHostState {
382
383 companion object {
384 const val EXPANDED: Float = 1.0f
385 const val COLLAPSED: Float = 0.0f
386 }
387
388 /**
389 * The last measurement input that this state was measured with. Infers width and height of the
390 * players.
391 */
392 var measurementInput: MeasurementInput?
393
394 /**
395 * The expansion of the player, [COLLAPSED] for fully collapsed (up to 3 actions), [EXPANDED]
396 * for fully expanded (up to 5 actions).
397 */
398 var expansion: Float
399
400 /**
401 * If true, the [EXPANDED] layout should stretch to match the height of its parent container,
402 * rather than having a fixed height.
403 */
404 var expandedMatchesParentHeight: Boolean
405
406 /** Fraction of the height animation. */
407 var squishFraction: Float
408
409 /** Is this host only showing active media or is it showing all of them including resumption? */
410 var showsOnlyActiveMedia: Boolean
411
412 /** If the view should be VISIBLE or GONE. */
413 val visible: Boolean
414
415 /** Does this host need any falsing protection? */
416 var falsingProtectionNeeded: Boolean
417
418 /**
419 * The parameters how the view disappears from this location when going to a host that's not
420 * visible. If modified, make sure to set this value again on the host to ensure the values are
421 * propagated
422 */
423 var disappearParameters: DisappearParameters
424
425 /**
426 * Whether scrolling should be disabled for this host, meaning that when there are multiple
427 * media sessions, it will not be possible to scroll between media sessions or swipe away the
428 * entire media carousel. The first media session will always be shown.
429 */
430 var disableScrolling: Boolean
431
432 /** Get a copy of this view state, deepcopying all appropriate members */
copynull433 fun copy(): MediaHostState
434 }
435