• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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