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