1 /*
<lambda>null2 * Copyright (C) 2024 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.domain.pipeline
18
19 import android.content.Context
20 import android.graphics.drawable.Animatable
21 import android.graphics.drawable.Drawable
22 import android.media.session.MediaController
23 import android.media.session.MediaSession
24 import android.os.Handler
25 import android.os.Looper
26 import android.util.Log
27 import androidx.annotation.WorkerThread
28 import androidx.media.utils.MediaConstants
29 import androidx.media3.common.Player
30 import androidx.media3.session.CommandButton
31 import androidx.media3.session.MediaController as Media3Controller
32 import androidx.media3.session.SessionCommand
33 import androidx.media3.session.SessionToken
34 import com.android.systemui.Flags
35 import com.android.systemui.dagger.SysUISingleton
36 import com.android.systemui.dagger.qualifiers.Application
37 import com.android.systemui.dagger.qualifiers.Background
38 import com.android.systemui.graphics.ImageLoader
39 import com.android.systemui.media.controls.shared.MediaControlDrawables
40 import com.android.systemui.media.controls.shared.MediaLogger
41 import com.android.systemui.media.controls.shared.model.MediaAction
42 import com.android.systemui.media.controls.shared.model.MediaButton
43 import com.android.systemui.media.controls.util.MediaControllerFactory
44 import com.android.systemui.media.controls.util.SessionTokenFactory
45 import com.android.systemui.res.R
46 import com.android.systemui.util.concurrency.Execution
47 import java.util.concurrent.ExecutionException
48 import javax.inject.Inject
49 import kotlinx.coroutines.CoroutineScope
50 import kotlinx.coroutines.launch
51 import kotlinx.coroutines.runBlocking
52 import kotlinx.coroutines.suspendCancellableCoroutine
53
54 private const val TAG = "Media3ActionFactory"
55
56 @SysUISingleton
57 class Media3ActionFactory
58 @Inject
59 constructor(
60 @Application val context: Context,
61 private val imageLoader: ImageLoader,
62 private val controllerFactory: MediaControllerFactory,
63 private val tokenFactory: SessionTokenFactory,
64 private val logger: MediaLogger,
65 @Background private val looper: Looper,
66 @Background private val handler: Handler,
67 @Background private val bgScope: CoroutineScope,
68 private val execution: Execution,
69 ) {
70
71 /**
72 * Generates action button info for this media session based on the Media3 session info
73 *
74 * @param packageName Package name for the media app
75 * @param controller The framework [MediaController] for the session
76 * @return The media action buttons, or null if cannot be created for this session
77 */
78 suspend fun createActionsFromSession(
79 packageName: String,
80 sessionToken: MediaSession.Token,
81 ): MediaButton? {
82 // Get the Media3 controller using the legacy token
83 val token = tokenFactory.createTokenFromLegacy(sessionToken)
84 val m3controller = controllerFactory.create(token, looper)
85 if (m3controller == null) {
86 logger.logCreateFailed(packageName, "createActionsFromSession")
87 return null
88 }
89
90 // Build button info
91 val buttons = suspendCancellableCoroutine { continuation ->
92 // Media3Controller methods must always be called from a specific looper
93 val runnable = Runnable {
94 try {
95 val result = getMedia3Actions(packageName, m3controller, token)
96 continuation.resumeWith(Result.success(result))
97 } finally {
98 m3controller.tryRelease(packageName, logger)
99 }
100 }
101 handler.post(runnable)
102 continuation.invokeOnCancellation {
103 // Ensure controller is released, even if loading was cancelled partway through
104 val releaseRunnable = Runnable { m3controller.tryRelease(packageName, logger) }
105 handler.post(releaseRunnable)
106 handler.removeCallbacks(runnable)
107 }
108 }
109 return buttons
110 }
111
112 /** This method must be called on the Media3 looper! */
113 @WorkerThread
114 private fun getMedia3Actions(
115 packageName: String,
116 m3controller: Media3Controller,
117 token: SessionToken,
118 ): MediaButton? {
119 require(!execution.isMainThread())
120
121 // First, get standard actions
122 val playOrPause =
123 if (m3controller.playbackState == Player.STATE_BUFFERING) {
124 // Spinner needs to be animating to render anything. Start it here.
125 val drawable =
126 context.getDrawable(com.android.internal.R.drawable.progress_small_material)
127 (drawable as Animatable).start()
128 MediaAction(
129 drawable,
130 null, // no action to perform when clicked
131 context.getString(R.string.controls_media_button_connecting),
132 if (Flags.mediaControlsUiUpdate()) {
133 context.getDrawable(R.drawable.ic_media_connecting_button_container)
134 } else {
135 context.getDrawable(R.drawable.ic_media_connecting_container)
136 },
137 // Specify a rebind id to prevent the spinner from restarting on later binds.
138 com.android.internal.R.drawable.progress_small_material,
139 )
140 } else {
141 getStandardAction(packageName, m3controller, token, Player.COMMAND_PLAY_PAUSE)
142 }
143
144 val prevButton =
145 getStandardAction(
146 packageName,
147 m3controller,
148 token,
149 Player.COMMAND_SEEK_TO_PREVIOUS,
150 Player.COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM,
151 )
152 val nextButton =
153 getStandardAction(
154 packageName,
155 m3controller,
156 token,
157 Player.COMMAND_SEEK_TO_NEXT,
158 Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM,
159 )
160
161 // Then, get custom actions
162 var customActions =
163 m3controller.customLayout
164 .asSequence()
165 .filter {
166 it.isEnabled &&
167 it.sessionCommand?.commandCode == SessionCommand.COMMAND_CODE_CUSTOM &&
168 m3controller.isSessionCommandAvailable(it.sessionCommand!!)
169 }
170 .map { getCustomAction(packageName, token, it) }
171 .iterator()
172 fun nextCustomAction() = if (customActions.hasNext()) customActions.next() else null
173
174 // Finally, assign the remaining button slots: play/pause A B C D
175 // A = previous, else custom action (if not reserved)
176 // B = next, else custom action (if not reserved)
177 // C and D are always custom actions
178 val reservePrev =
179 m3controller.sessionExtras.getBoolean(
180 MediaConstants.SESSION_EXTRAS_KEY_SLOT_RESERVATION_SKIP_TO_PREV,
181 false,
182 )
183 val reserveNext =
184 m3controller.sessionExtras.getBoolean(
185 MediaConstants.SESSION_EXTRAS_KEY_SLOT_RESERVATION_SKIP_TO_NEXT,
186 false,
187 )
188
189 val prevOrCustom =
190 prevButton
191 ?: if (reservePrev) {
192 null
193 } else {
194 nextCustomAction()
195 }
196
197 val nextOrCustom =
198 nextButton
199 ?: if (reserveNext) {
200 null
201 } else {
202 nextCustomAction()
203 }
204
205 return MediaButton(
206 playOrPause = playOrPause,
207 nextOrCustom = nextOrCustom,
208 prevOrCustom = prevOrCustom,
209 custom0 = nextCustomAction(),
210 custom1 = nextCustomAction(),
211 reserveNext = reserveNext,
212 reservePrev = reservePrev,
213 )
214 }
215
216 /**
217 * Create a [MediaAction] for a given command, if supported
218 *
219 * @param controller Media3 controller for the session
220 * @param commands Commands to check, in priority order
221 * @return A [MediaAction] representing the first supported command, or null if not supported
222 */
223 private fun getStandardAction(
224 packageName: String,
225 controller: Media3Controller,
226 token: SessionToken,
227 vararg commands: @Player.Command Int,
228 ): MediaAction? {
229 for (command in commands) {
230 if (!controller.isCommandAvailable(command)) {
231 continue
232 }
233
234 return when (command) {
235 Player.COMMAND_PLAY_PAUSE -> {
236 if (!controller.isPlaying) {
237 MediaAction(
238 if (Flags.mediaControlsUiUpdate()) {
239 context.getDrawable(R.drawable.ic_media_play_button)
240 } else {
241 context.getDrawable(R.drawable.ic_media_play)
242 },
243 { executeAction(packageName, token, Player.COMMAND_PLAY_PAUSE) },
244 context.getString(R.string.controls_media_button_play),
245 if (Flags.mediaControlsUiUpdate()) {
246 context.getDrawable(R.drawable.ic_media_play_button_container)
247 } else {
248 context.getDrawable(R.drawable.ic_media_play_container)
249 },
250 )
251 } else {
252 MediaAction(
253 if (Flags.mediaControlsUiUpdate()) {
254 context.getDrawable(R.drawable.ic_media_pause_button)
255 } else {
256 context.getDrawable(R.drawable.ic_media_pause)
257 },
258 { executeAction(packageName, token, Player.COMMAND_PLAY_PAUSE) },
259 context.getString(R.string.controls_media_button_pause),
260 if (Flags.mediaControlsUiUpdate()) {
261 context.getDrawable(R.drawable.ic_media_pause_button_container)
262 } else {
263 context.getDrawable(R.drawable.ic_media_pause_container)
264 },
265 )
266 }
267 }
268 else -> {
269 MediaAction(
270 icon = getIconForAction(command),
271 action = { executeAction(packageName, token, command) },
272 contentDescription = getDescriptionForAction(command),
273 background = null,
274 )
275 }
276 }
277 }
278 return null
279 }
280
281 /** Get a [MediaAction] representing a [CommandButton] */
282 private fun getCustomAction(
283 packageName: String,
284 token: SessionToken,
285 customAction: CommandButton,
286 ): MediaAction {
287 return MediaAction(
288 getIconForAction(customAction, packageName),
289 { executeAction(packageName, token, Player.COMMAND_INVALID, customAction) },
290 customAction.displayName,
291 null,
292 )
293 }
294
295 private fun getIconForAction(command: @Player.Command Int): Drawable? {
296 return when (command) {
297 Player.COMMAND_SEEK_TO_PREVIOUS -> MediaControlDrawables.getPrevIcon(context)
298 Player.COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM -> MediaControlDrawables.getPrevIcon(context)
299 Player.COMMAND_SEEK_TO_NEXT -> MediaControlDrawables.getNextIcon(context)
300 Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM -> MediaControlDrawables.getNextIcon(context)
301 else -> {
302 Log.e(TAG, "Unknown icon for $command")
303 null
304 }
305 }
306 }
307
308 private fun getIconForAction(customAction: CommandButton, packageName: String): Drawable? {
309 val size = context.resources.getDimensionPixelSize(R.dimen.min_clickable_item_size)
310 // TODO(b/360196209): check customAction.icon field to use platform icons
311 if (customAction.iconResId != 0) {
312 val packageContext = context.createPackageContext(packageName, 0)
313 val source = ImageLoader.Res(customAction.iconResId, packageContext)
314 return runBlocking { imageLoader.loadDrawable(source, size, size) }
315 }
316
317 if (customAction.iconUri != null) {
318 val source = ImageLoader.Uri(customAction.iconUri!!)
319 return runBlocking { imageLoader.loadDrawable(source, size, size) }
320 }
321 return null
322 }
323
324 private fun getDescriptionForAction(command: @Player.Command Int): String? {
325 return when (command) {
326 Player.COMMAND_SEEK_TO_PREVIOUS ->
327 context.getString(R.string.controls_media_button_prev)
328 Player.COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM ->
329 context.getString(R.string.controls_media_button_prev)
330 Player.COMMAND_SEEK_TO_NEXT -> context.getString(R.string.controls_media_button_next)
331 Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM ->
332 context.getString(R.string.controls_media_button_next)
333 else -> {
334 Log.e(TAG, "Unknown content description for $command")
335 null
336 }
337 }
338 }
339
340 private fun executeAction(
341 packageName: String,
342 token: SessionToken,
343 command: Int,
344 customAction: CommandButton? = null,
345 ) {
346 bgScope.launch {
347 val controller = controllerFactory.create(token, looper)
348 if (controller == null) {
349 logger.logCreateFailed(packageName, "executeAction")
350 return@launch
351 }
352 handler.post {
353 try {
354 when (command) {
355 Player.COMMAND_PLAY_PAUSE -> {
356 if (controller.isPlaying) controller.pause() else controller.play()
357 }
358
359 Player.COMMAND_SEEK_TO_PREVIOUS -> controller.seekToPrevious()
360 Player.COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM ->
361 controller.seekToPreviousMediaItem()
362
363 Player.COMMAND_SEEK_TO_NEXT -> controller.seekToNext()
364 Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM -> controller.seekToNextMediaItem()
365 Player.COMMAND_INVALID -> {
366 if (customAction?.sessionCommand != null) {
367 val sessionCommand = customAction.sessionCommand!!
368 if (controller.isSessionCommandAvailable(sessionCommand)) {
369 controller.sendCustomCommand(
370 sessionCommand,
371 customAction.extras,
372 )
373 } else {
374 logger.logMedia3UnsupportedCommand(
375 "$sessionCommand, action $customAction"
376 )
377 }
378 } else {
379 logger.logMedia3UnsupportedCommand("$command, action $customAction")
380 }
381 }
382 else -> logger.logMedia3UnsupportedCommand(command.toString())
383 }
384 } finally {
385 controller.tryRelease(packageName, logger)
386 }
387 }
388 }
389 }
390 }
391
tryReleasenull392 private fun Media3Controller.tryRelease(packageName: String, logger: MediaLogger) {
393 try {
394 this.release()
395 } catch (e: ExecutionException) {
396 logger.logReleaseFailed(packageName, e.cause.toString())
397 }
398 }
399