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 39 ) : 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 */ 383 fun copy(): MediaHostState 384 } 385