react-native-audio-browser 0.2.0-next.3 → 0.2.0-next.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/android/src/main/java/com/audiobrowser/model/PlayerUpdateOptions.kt +11 -11
  2. package/android/src/main/java/com/audiobrowser/player/CapabilityControls.kt +111 -58
  3. package/android/src/main/java/com/audiobrowser/player/MediaSessionCallback.kt +9 -5
  4. package/android/src/main/java/com/audiobrowser/player/MediaSessionCommandManager.kt +218 -252
  5. package/android/src/main/java/com/audiobrowser/player/Player.kt +19 -8
  6. package/android/src/test/java/com/audiobrowser/player/CapabilityControlsTest.kt +100 -56
  7. package/android/src/test/java/com/audiobrowser/player/MediaSessionSlotMappingTest.kt +45 -0
  8. package/lib/commonjs/features/player/options.js +45 -15
  9. package/lib/commonjs/features/player/options.js.map +1 -1
  10. package/lib/commonjs/features/player/setup.js +13 -6
  11. package/lib/commonjs/features/player/setup.js.map +1 -1
  12. package/lib/module/features/player/options.js +45 -15
  13. package/lib/module/features/player/options.js.map +1 -1
  14. package/lib/module/features/player/setup.js +13 -6
  15. package/lib/module/features/player/setup.js.map +1 -1
  16. package/lib/typescript/src/features/player/options.d.ts +84 -35
  17. package/lib/typescript/src/features/player/options.d.ts.map +1 -1
  18. package/lib/typescript/src/features/player/setup.d.ts +10 -3
  19. package/lib/typescript/src/features/player/setup.d.ts.map +1 -1
  20. package/nitrogen/generated/android/AudioBrowser+autolinking.cmake +1 -1
  21. package/nitrogen/generated/android/c++/JAndroidOptions.hpp +10 -10
  22. package/nitrogen/generated/android/c++/JFunc_void_Options.hpp +5 -5
  23. package/nitrogen/generated/android/c++/JHybridAudioBrowserSpec.cpp +9 -9
  24. package/nitrogen/generated/android/c++/JNativeSetupPlayerOptions.hpp +5 -5
  25. package/nitrogen/generated/android/c++/JNativeUpdateOptions.hpp +5 -5
  26. package/nitrogen/generated/android/c++/JNitroAndroidUpdateOptions.hpp +10 -10
  27. package/nitrogen/generated/android/c++/JOptions.hpp +5 -5
  28. package/nitrogen/generated/android/c++/{JNotificationButton.hpp → JRemoteButton.hpp} +19 -19
  29. package/nitrogen/generated/android/c++/JRemoteButtonLayout.hpp +86 -0
  30. package/nitrogen/generated/android/c++/JVariant_NullType_RemoteButtonLayout.cpp +26 -0
  31. package/nitrogen/generated/android/c++/JVariant_NullType_RemoteButtonLayout.hpp +75 -0
  32. package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/AndroidOptions.kt +3 -3
  33. package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/NitroAndroidUpdateOptions.kt +3 -3
  34. package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/{NotificationButton.kt → RemoteButton.kt} +3 -3
  35. package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/RemoteButtonLayout.kt +44 -0
  36. package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/{Variant_NullType_NotificationButtonLayout.kt → Variant_NullType_RemoteButtonLayout.kt} +9 -9
  37. package/nitrogen/generated/ios/AudioBrowser-Swift-Cxx-Bridge.hpp +36 -51
  38. package/nitrogen/generated/ios/AudioBrowser-Swift-Cxx-Umbrella.hpp +6 -6
  39. package/nitrogen/generated/ios/c++/HybridAudioBrowserSpecSwift.hpp +6 -6
  40. package/nitrogen/generated/ios/swift/AndroidOptions.swift +12 -12
  41. package/nitrogen/generated/ios/swift/NitroAndroidUpdateOptions.swift +12 -12
  42. package/nitrogen/generated/ios/swift/{NotificationButton.swift → RemoteButton.swift} +6 -6
  43. package/nitrogen/generated/ios/swift/RemoteButtonLayout.swift +57 -0
  44. package/nitrogen/generated/ios/swift/{Variant_NullType_NotificationButtonLayout.swift → Variant_NullType_RemoteButtonLayout.swift} +3 -3
  45. package/nitrogen/generated/shared/c++/AndroidOptions.hpp +8 -8
  46. package/nitrogen/generated/shared/c++/NitroAndroidUpdateOptions.hpp +8 -8
  47. package/nitrogen/generated/shared/c++/{NotificationButton.hpp → RemoteButton.hpp} +19 -19
  48. package/nitrogen/generated/shared/c++/RemoteButtonLayout.hpp +94 -0
  49. package/package.json +1 -1
  50. package/src/features/player/options.ts +95 -36
  51. package/src/features/player/setup.test.ts +12 -4
  52. package/src/features/player/setup.ts +13 -6
  53. package/nitrogen/generated/android/c++/JNotificationButtonLayout.hpp +0 -94
  54. package/nitrogen/generated/android/c++/JVariant_NullType_NotificationButtonLayout.cpp +0 -26
  55. package/nitrogen/generated/android/c++/JVariant_NullType_NotificationButtonLayout.hpp +0 -75
  56. package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/NotificationButtonLayout.kt +0 -50
  57. package/nitrogen/generated/ios/swift/NotificationButtonLayout.swift +0 -92
  58. package/nitrogen/generated/shared/c++/NotificationButtonLayout.hpp +0 -102
@@ -4,26 +4,50 @@ import android.os.Bundle
4
4
  import androidx.media3.common.Player as MediaPlayer
5
5
  import androidx.media3.session.CommandButton
6
6
  import androidx.media3.session.MediaSession
7
- import androidx.media3.session.R
8
7
  import androidx.media3.session.SessionCommand
9
8
  import androidx.media3.session.SessionCommands
10
- import com.margelo.nitro.audiobrowser.NotificationButton
11
- import com.margelo.nitro.audiobrowser.NotificationButtonLayout
12
9
  import com.margelo.nitro.audiobrowser.PlayerCapabilities
10
+ import com.margelo.nitro.audiobrowser.RemoteButton
11
+ import com.margelo.nitro.audiobrowser.RemoteButtonLayout
13
12
  import timber.log.Timber
14
13
 
14
+ /**
15
+ * A slot's Media3 preference list: the requested slot, then OVERFLOW as a fallback.
16
+ *
17
+ * `slots` is a preference order, not a coordinate — Media3 places a button in the first slot in the
18
+ * list "that exists, isn't already occupied and that allows this type of button". Naming OVERFLOW
19
+ * second means a button whose primary position is unavailable on some surface demotes instead of
20
+ * disappearing, because every flattening
21
+ * (`CommandButton.getCustomLayoutFromMediaButtonPreferences`, run by both `MediaSessionLegacyStub`
22
+ * for Android Auto and the Android 13+ controls, and `DefaultMediaNotificationProvider` for the
23
+ * notification) keeps a button only if it won BACK, won FORWARD, or *contains* OVERFLOW.
24
+ */
25
+ internal fun media3SlotsFor(slot: ButtonSlot): IntArray =
26
+ when (slot) {
27
+ ButtonSlot.BACK -> intArrayOf(CommandButton.SLOT_BACK, CommandButton.SLOT_OVERFLOW)
28
+ ButtonSlot.FORWARD -> intArrayOf(CommandButton.SLOT_FORWARD, CommandButton.SLOT_OVERFLOW)
29
+ ButtonSlot.OVERFLOW -> intArrayOf(CommandButton.SLOT_OVERFLOW)
30
+ }
31
+
15
32
  /**
16
33
  * MediaSession manager that handles command configuration and execution.
17
34
  *
18
35
  * Responsibilities:
19
- * - Maps player capabilities to MediaSession commands and notification layouts
36
+ * - Maps player capabilities to MediaSession commands
37
+ * - Builds the single, slot-annotated button layout published to every surface
20
38
  * - Updates MediaSession configuration and applies changes immediately
21
39
  * - Builds connection results for new MediaSession controllers
22
40
  * - Handles execution of custom MediaSession commands (jump actions)
23
- * - Maintains proper separation between global capabilities and notification-specific controls
24
41
  *
25
- * Initializes with sensible defaults: all global capabilities enabled, essential notification
26
- * controls only.
42
+ * Capabilities decide what the player can do; the button layout decides where those buttons sit.
43
+ * Placement never revokes a capability — a control left out of the layout still works from a
44
+ * Bluetooth remote or headset.
45
+ *
46
+ * There is ONE layout, published session-wide via `setMediaButtonPreferences`. Media3 fans it out:
47
+ * `MediaNotificationManager` renders it as the MediaStyle notification (Android 12 and below), and
48
+ * `MediaSessionLegacyStub` converts it into the platform PlaybackState plus the slot-reservation
49
+ * extras that Android Auto and the Android 13+ system media controls read. Publishing
50
+ * per-controller instead would reach neither, since Android Auto connects as a legacy controller.
27
51
  */
28
52
  class MediaSessionCommandManager {
29
53
 
@@ -31,31 +55,51 @@ class MediaSessionCommandManager {
31
55
  private const val CUSTOM_ACTION_JUMP_BACKWARD = "JUMP_BACKWARD"
32
56
  private const val CUSTOM_ACTION_JUMP_FORWARD = "JUMP_FORWARD"
33
57
  const val CUSTOM_ACTION_FAVORITE = "FAVORITE"
34
- }
35
58
 
36
- /** Current player commands configuration for external controllers */
37
- var playerCommands: MediaPlayer.Commands = MediaSession.ConnectionResult.DEFAULT_PLAYER_COMMANDS
38
- private set
59
+ /** Matches PlayerUpdateOptions.forwardJumpInterval/backwardJumpInterval */
60
+ private const val DEFAULT_JUMP_INTERVAL = 15.0
61
+ }
39
62
 
40
- /** Current player commands configuration for notification controller */
41
- var notificationPlayerCommands: MediaPlayer.Commands =
42
- MediaSession.ConnectionResult.DEFAULT_PLAYER_COMMANDS
43
- private set
63
+ /**
64
+ * Media3 ships numbered icons for 5, 10, 15 and 30 seconds; every other interval falls back to
65
+ * the unnumbered arrow.
66
+ *
67
+ * The match is exact, not rounded. A numbered icon is a claim about how far the button seeks, so
68
+ * a 14.6s interval must not draw "15" — it would promise a jump the player never makes. Better an
69
+ * honest arrow than a wrong number.
70
+ *
71
+ * The icon constant — not the drawable — is what a controller that renders its own UI reads, and
72
+ * it is carried through the legacy conversion Android Auto consumes. Building a button with only
73
+ * `setIconResId` leaves the constant at ICON_UNDEFINED, so the car has nothing to draw.
74
+ */
75
+ private fun jumpForwardIcon(seconds: Double): Int =
76
+ when (seconds) {
77
+ 5.0 -> CommandButton.ICON_SKIP_FORWARD_5
78
+ 10.0 -> CommandButton.ICON_SKIP_FORWARD_10
79
+ 15.0 -> CommandButton.ICON_SKIP_FORWARD_15
80
+ 30.0 -> CommandButton.ICON_SKIP_FORWARD_30
81
+ else -> CommandButton.ICON_SKIP_FORWARD
82
+ }
44
83
 
45
- /** Current session commands configuration for external controllers */
46
- lateinit var sessionCommands: SessionCommands
47
- private set
84
+ private fun jumpBackwardIcon(seconds: Double): Int =
85
+ when (seconds) {
86
+ 5.0 -> CommandButton.ICON_SKIP_BACK_5
87
+ 10.0 -> CommandButton.ICON_SKIP_BACK_10
88
+ 15.0 -> CommandButton.ICON_SKIP_BACK_15
89
+ 30.0 -> CommandButton.ICON_SKIP_BACK_30
90
+ else -> CommandButton.ICON_SKIP_BACK
91
+ }
48
92
 
49
- /** Current session commands configuration for notification */
50
- lateinit var notificationSessionCommands: SessionCommands
93
+ /** Player commands available to every controller, derived from capabilities alone */
94
+ var playerCommands: MediaPlayer.Commands = MediaSession.ConnectionResult.DEFAULT_PLAYER_COMMANDS
51
95
  private set
52
96
 
53
- /** Current custom layout configuration for external controllers */
54
- lateinit var customLayout: List<CommandButton>
97
+ /** Session commands available to every controller, derived from capabilities alone */
98
+ lateinit var sessionCommands: SessionCommands
55
99
  private set
56
100
 
57
- /** Current custom layout configuration for notification */
58
- lateinit var notificationCustomLayout: List<CommandButton>
101
+ /** The single slot-annotated button layout, published to every surface */
102
+ lateinit var buttonPreferences: List<CommandButton>
59
103
  private set
60
104
 
61
105
  /** Stored state for rebuilding layout */
@@ -74,41 +118,37 @@ class MediaSessionCommandManager {
74
118
  repeatMode = null,
75
119
  playbackRate = null,
76
120
  )
77
- private var currentNotificationButtons: NotificationButtonLayout? = null
78
- private var currentSearchAvailable: Boolean = false
121
+ private var currentRemoteButtonLayout: RemoteButtonLayout? = null
79
122
  private var currentFavorited: Boolean? = null
123
+ private var currentForwardJumpInterval: Double = DEFAULT_JUMP_INTERVAL
124
+ private var currentBackwardJumpInterval: Double = DEFAULT_JUMP_INTERVAL
80
125
 
81
- /**
82
- * The Media3 player command behind each Capability-gated control (FAVORITE is a session command).
83
- */
84
- private val controlPlayerCommands: Map<Control, @MediaPlayer.Command Int> =
126
+ /** The Media3 player command behind each Capability (FAVORITE is a session command). */
127
+ private val capabilityPlayerCommands: Map<Capability, @MediaPlayer.Command Int> =
85
128
  mapOf(
86
- Control.PLAY_PAUSE to MediaPlayer.COMMAND_PLAY_PAUSE,
87
- Control.STOP to MediaPlayer.COMMAND_STOP,
88
- Control.SEEK_TO to MediaPlayer.COMMAND_SEEK_IN_CURRENT_MEDIA_ITEM,
89
- Control.SKIP_TO_NEXT to MediaPlayer.COMMAND_SEEK_TO_NEXT,
90
- Control.SKIP_TO_PREVIOUS to MediaPlayer.COMMAND_SEEK_TO_PREVIOUS,
91
- Control.JUMP_FORWARD to MediaPlayer.COMMAND_SEEK_FORWARD,
92
- Control.JUMP_BACKWARD to MediaPlayer.COMMAND_SEEK_BACK,
129
+ Capability.PLAY_PAUSE to MediaPlayer.COMMAND_PLAY_PAUSE,
130
+ Capability.STOP to MediaPlayer.COMMAND_STOP,
131
+ Capability.SEEK_TO to MediaPlayer.COMMAND_SEEK_IN_CURRENT_MEDIA_ITEM,
132
+ Capability.SKIP_TO_NEXT to MediaPlayer.COMMAND_SEEK_TO_NEXT,
133
+ Capability.SKIP_TO_PREVIOUS to MediaPlayer.COMMAND_SEEK_TO_PREVIOUS,
134
+ Capability.JUMP_FORWARD to MediaPlayer.COMMAND_SEEK_FORWARD,
135
+ Capability.JUMP_BACKWARD to MediaPlayer.COMMAND_SEEK_BACK,
93
136
  )
94
137
 
95
138
  // Declared above `init` deliberately: `init` calls `buildPlayerCommands`, which reads
96
- // `controlPlayerCommands`. Kotlin initializes properties in textual order, so this val must
139
+ // `capabilityPlayerCommands`. Kotlin initializes properties in textual order, so this val must
97
140
  // precede the init block or it would still be null when the constructor runs (NPE).
98
141
  init {
99
142
  // Initialize with defaults matching PlayerUpdateOptions (all capabilities enabled)
100
143
  val defaultCapabilities = currentCapabilities
101
144
 
102
145
  playerCommands = buildPlayerCommands(defaultCapabilities)
103
- notificationPlayerCommands = buildPlayerCommands(defaultCapabilities)
104
-
105
- val (extSessionCommands, extCustomLayout) =
106
- buildSessionCommandsAndLayout(defaultCapabilities, searchAvailable = false, favorited = null)
107
- sessionCommands = extSessionCommands
108
- customLayout = extCustomLayout
109
-
110
- notificationSessionCommands = extSessionCommands
111
- notificationCustomLayout = extCustomLayout
146
+ sessionCommands = buildSessionCommands(defaultCapabilities, searchAvailable = false)
147
+ buttonPreferences =
148
+ buildButtonPreferences(
149
+ deriveButtonSlots(defaultCapabilities, layout = null),
150
+ favorited = null,
151
+ )
112
152
  }
113
153
 
114
154
  /**
@@ -118,61 +158,72 @@ class MediaSessionCommandManager {
118
158
  * @param capabilities Global capabilities that enable commands for ALL MediaSession controllers
119
159
  * (Bluetooth, Android Auto, lock screen, notification, etc.). All capabilities are enabled by
120
160
  * default - only false values disable them.
121
- * @param notificationButtons Slot-based button layout for notifications. When null, button layout
122
- * is derived from capabilities using smart defaults.
161
+ * @param remoteButtonLayout Button layout, published to every surface — the notification, Android
162
+ * Auto, and the Android 13+ system media controls. When null the layout is derived from
163
+ * capabilities using smart defaults.
123
164
  * @param searchAvailable Whether search functionality is configured and available
165
+ * @param forwardJumpInterval Seconds per forward jump; selects the button's icon constant
166
+ * @param backwardJumpInterval Seconds per backward jump; selects the button's icon constant
124
167
  *
125
- * Manager initializes with defaults: all global capabilities, limited notification capabilities.
168
+ * Manager initializes with defaults: all capabilities enabled, default placement.
126
169
  */
127
170
  fun updateMediaSession(
128
171
  mediaSession: MediaSession,
129
172
  capabilities: PlayerCapabilities,
130
- notificationButtons: NotificationButtonLayout?,
173
+ remoteButtonLayout: RemoteButtonLayout?,
131
174
  searchAvailable: Boolean,
132
- favorited: Boolean? = null,
175
+ forwardJumpInterval: Double = DEFAULT_JUMP_INTERVAL,
176
+ backwardJumpInterval: Double = DEFAULT_JUMP_INTERVAL,
133
177
  ) {
134
- // Store state for future rebuilds
178
+ // Store state for future rebuilds. The favorite state is deliberately NOT reset here: options
179
+ // and favorites change independently, and rebuilding the layout as un-favorited would blank the
180
+ // heart on every updateOptions() call until the next track change happened to refresh it.
135
181
  currentCapabilities = capabilities
136
- currentNotificationButtons = notificationButtons
137
- currentSearchAvailable = searchAvailable
138
- currentFavorited = favorited
182
+ currentRemoteButtonLayout = remoteButtonLayout
183
+ currentForwardJumpInterval = forwardJumpInterval
184
+ currentBackwardJumpInterval = backwardJumpInterval
139
185
 
140
- // Build commands for external controllers (global capabilities)
186
+ // Commands gate on capabilities only — placement never revokes one, so a control left out of
187
+ // the layout still works from a Bluetooth remote or headset.
141
188
  playerCommands = buildPlayerCommands(capabilities)
142
- val (extSessionCommands, extCustomLayout) =
143
- buildSessionCommandsAndLayout(capabilities, searchAvailable, favorited)
144
- sessionCommands = extSessionCommands
145
- customLayout = extCustomLayout
146
-
147
- // One slot derivation feeds both the notification layout and its player
148
- // commands, so the buttons shown and the commands enabled cannot drift.
149
- val slots = deriveNotificationSlots(capabilities, notificationButtons)
150
- val (notifSessionCommands, notifButtonPrefs) =
151
- buildNotificationButtonPreferences(slots, searchAvailable, favorited)
152
- notificationSessionCommands = notifSessionCommands
153
- notificationCustomLayout = notifButtonPrefs
154
- notificationPlayerCommands = buildNotificationPlayerCommands(capabilities, slots)
155
-
156
- // Apply media button preferences to notification controller
157
- mediaSession.mediaNotificationControllerInfo?.let { controllerInfo ->
158
- mediaSession.setMediaButtonPreferences(controllerInfo, notifButtonPrefs)
159
- mediaSession.setAvailableCommands(
160
- controllerInfo,
161
- notificationSessionCommands,
162
- notificationPlayerCommands,
163
- )
189
+ sessionCommands = buildSessionCommands(capabilities, searchAvailable)
190
+ buttonPreferences =
191
+ buildButtonPreferences(deriveButtonSlots(capabilities, remoteButtonLayout), currentFavorited)
192
+
193
+ // Push the rebuilt commands to already-connected controllers — buildConnectionResult only
194
+ // serves controllers that connect later, and an Android Auto session lives until the car shuts
195
+ // down. Granted before the layout broadcast so a newly-added button arrives with its command
196
+ // already available; Media3 disables buttons whose backing command is missing.
197
+ mediaSession.connectedControllers.forEach {
198
+ mediaSession.setAvailableCommands(it, sessionCommands, playerCommands)
164
199
  }
165
200
 
166
- // Push the rebuilt commands to already-connected external controllers (Android Auto,
167
- // Bluetooth) — buildConnectionResult only serves controllers that connect later, and an
168
- // Android Auto session lives until the car shuts down. Granted before the layout broadcast
169
- // so a newly-added button arrives with its command already available.
170
- mediaSession.connectedControllers
171
- .filterNot { mediaSession.isMediaNotificationController(it) }
172
- .forEach { mediaSession.setAvailableCommands(it, sessionCommands, playerCommands) }
201
+ publishButtonPreferences(mediaSession)
202
+ }
173
203
 
174
- // Broadcast updated layout to all external controllers (Android Auto, etc.)
175
- mediaSession.setCustomLayout(customLayout)
204
+ /**
205
+ * Publishes the layout. Media3 fans it out to the MediaStyle notification (Android 12 and below)
206
+ * and to the platform PlaybackState plus slot-reservation extras (Android Auto and the Android
207
+ * 13+ system media controls).
208
+ *
209
+ * Both calls are required. The session-wide call is the only one that reaches Android Auto, which
210
+ * connects as a legacy controller rather than a Media3 one. But it stops at
211
+ * `setPlatformMediaButtonPreferences`, which recomputes the legacy custom layout and only
212
+ * re-broadcasts when the reservation flags themselves change — it never refreshes the
213
+ * PlaybackState carrying the custom actions. Only the notification-controller overload calls
214
+ * `updateLegacySessionPlaybackState`, so without it a reordering that leaves the flags untouched
215
+ * sits invisible until some unrelated playback event republishes the state.
216
+ */
217
+ private fun publishButtonPreferences(mediaSession: MediaSession) {
218
+ Timber.Forest.d(
219
+ "Publishing ${buttonPreferences.size} button preferences: " +
220
+ "${buttonPreferences.map { it.displayName }}"
221
+ )
222
+ mediaSession.setMediaButtonPreferences(buttonPreferences)
223
+
224
+ mediaSession.mediaNotificationControllerInfo?.let { controllerInfo ->
225
+ mediaSession.setMediaButtonPreferences(controllerInfo, buttonPreferences)
226
+ }
176
227
  }
177
228
 
178
229
  /**
@@ -190,32 +241,16 @@ class MediaSessionCommandManager {
190
241
 
191
242
  currentFavorited = favorited
192
243
 
193
- // Rebuild external controller layout with new favorite state
194
- val (extSessionCommands, extCustomLayout) =
195
- buildSessionCommandsAndLayout(currentCapabilities, currentSearchAvailable, favorited)
196
- sessionCommands = extSessionCommands
197
- customLayout = extCustomLayout
198
- Timber.Forest.d("Built external customLayout with ${customLayout.size} buttons")
199
-
200
- // Rebuild notification button preferences with new favorite state
201
- val (notifSessionCommands, notifButtonPrefs) =
202
- buildNotificationButtonPreferences(
203
- deriveNotificationSlots(currentCapabilities, currentNotificationButtons),
204
- currentSearchAvailable,
244
+ // Only the heart's icon and label change, so the commands stay as they are.
245
+ buttonPreferences =
246
+ buildButtonPreferences(
247
+ deriveButtonSlots(currentCapabilities, currentRemoteButtonLayout),
205
248
  favorited,
206
249
  )
207
- notificationSessionCommands = notifSessionCommands
208
- notificationCustomLayout = notifButtonPrefs
209
-
210
- // Apply updated button preferences to notification controller
211
- mediaSession.mediaNotificationControllerInfo?.let { controllerInfo ->
212
- Timber.Forest.d("Updating notification controller button preferences")
213
- mediaSession.setMediaButtonPreferences(controllerInfo, notificationCustomLayout)
214
- }
215
250
 
216
- // Broadcast the rebuilt layout so already-connected external controllers (Android Auto)
217
- // flip the heart icon too — without this it stays stale for the life of the connection.
218
- mediaSession.setCustomLayout(customLayout)
251
+ // Republish so already-connected surfaces (Android Auto especially) flip the heart — without
252
+ // this it stays stale for the life of the connection.
253
+ publishButtonPreferences(mediaSession)
219
254
  }
220
255
 
221
256
  /**
@@ -226,7 +261,7 @@ class MediaSessionCommandManager {
226
261
  */
227
262
  fun buildConnectionResult(session: MediaSession): MediaSession.ConnectionResult {
228
263
  return MediaSession.ConnectionResult.AcceptedResultBuilder(session)
229
- .setCustomLayout(customLayout)
264
+ .setMediaButtonPreferences(buttonPreferences)
230
265
  .setAvailableSessionCommands(sessionCommands)
231
266
  .setAvailablePlayerCommands(playerCommands)
232
267
  .build()
@@ -274,22 +309,26 @@ class MediaSessionCommandManager {
274
309
  playerCommandsBuilder.remove(MediaPlayer.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM)
275
310
  playerCommandsBuilder.remove(MediaPlayer.COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM)
276
311
 
277
- controlPlayerCommands.forEach { (control, command) ->
278
- if (!capabilities.isEnabled(control)) {
312
+ capabilityPlayerCommands.forEach { (capability, command) ->
313
+ if (!capabilities.isEnabled(capability)) {
279
314
  playerCommandsBuilder.remove(command)
280
- Timber.Forest.d("Removed command: $command ($control disabled)")
315
+ Timber.Forest.d("Removed command: $command ($capability disabled)")
281
316
  }
282
317
  }
283
318
 
284
319
  return playerCommandsBuilder.build()
285
320
  }
286
321
 
287
- private fun buildSessionCommandsAndLayout(
322
+ /**
323
+ * Session commands for every controller. Registered for each capability-allowed custom button
324
+ * whether or not the layout places it — placement governs where a button appears, not whether its
325
+ * command exists. Media3 disables a button whose backing command is missing, so every button the
326
+ * layout can produce must have its command here.
327
+ */
328
+ private fun buildSessionCommands(
288
329
  capabilities: PlayerCapabilities,
289
330
  searchAvailable: Boolean,
290
- favorited: Boolean?,
291
- ): Pair<SessionCommands, List<CommandButton>> {
292
- val customLayoutButtons = mutableListOf<CommandButton>()
331
+ ): SessionCommands {
293
332
  val sessionCommandsBuilder =
294
333
  MediaSession.ConnectionResult.DEFAULT_SESSION_AND_LIBRARY_COMMANDS.buildUpon()
295
334
 
@@ -300,157 +339,84 @@ class MediaSessionCommandManager {
300
339
  Timber.Forest.d("Removed search commands - search not configured")
301
340
  }
302
341
 
303
- // Custom command buttons: jump commands (required for notification visibility) and the
304
- // favorite heart — slot-less here; the notification path assigns slots to the same buttons.
305
- val externalButtons =
306
- listOf(
307
- NotificationButton.JUMP_BACKWARD,
308
- NotificationButton.JUMP_FORWARD,
309
- NotificationButton.FAVORITE,
310
- )
311
- .filter { capabilities.allows(it) }
312
- for (button in externalButtons) {
313
- val spec = buttonFor(button, favorited)
314
- customLayoutButtons.add(spec.builder.build())
315
- spec.sessionCommand?.let { sessionCommandsBuilder.add(it) }
316
- }
342
+ RemoteButton.entries
343
+ .filter { capabilities.allows(it) }
344
+ .mapNotNull { customCommandFor(it) }
345
+ .forEach { sessionCommandsBuilder.add(it) }
317
346
 
318
- return Pair(sessionCommandsBuilder.build(), customLayoutButtons)
347
+ return sessionCommandsBuilder.build()
319
348
  }
320
349
 
321
350
  /**
322
- * A button's definition plus, for session-command-backed buttons (jump/favorite), the custom
323
- * command the caller must register on its SessionCommands; null for player-command-backed buttons
324
- * (skip). The caller also assigns slots.
351
+ * The custom command behind a session-command-backed button, or null for the player-command ones
352
+ * (skip). Single source for both the command a button carries and the SessionCommands registry,
353
+ * so a published button can never be missing the command that makes it work — Media3 silently
354
+ * disables buttons whose backing command is unavailable.
325
355
  */
326
- private data class ButtonSpec(
327
- val builder: CommandButton.Builder,
328
- val sessionCommand: SessionCommand?,
329
- )
330
-
331
- /**
332
- * The single definition of a control's display name, icon, and action, shared by the
333
- * external-controller layout and the notification layout.
334
- */
335
- private fun buttonFor(button: NotificationButton, favorited: Boolean?): ButtonSpec =
356
+ private fun customCommandFor(button: RemoteButton): SessionCommand? =
336
357
  when (button) {
337
- NotificationButton.SKIP_TO_PREVIOUS ->
338
- CommandButton.Builder(CommandButton.ICON_PREVIOUS)
339
- .setDisplayName("Previous")
340
- .setPlayerCommand(MediaPlayer.COMMAND_SEEK_TO_PREVIOUS)
341
- .let { ButtonSpec(it, sessionCommand = null) }
342
- NotificationButton.SKIP_TO_NEXT ->
343
- CommandButton.Builder(CommandButton.ICON_NEXT)
344
- .setDisplayName("Next")
345
- .setPlayerCommand(MediaPlayer.COMMAND_SEEK_TO_NEXT)
346
- .let { ButtonSpec(it, sessionCommand = null) }
347
- NotificationButton.JUMP_BACKWARD -> {
348
- val command = SessionCommand(CUSTOM_ACTION_JUMP_BACKWARD, Bundle())
349
- CommandButton.Builder()
350
- .setDisplayName("Jump Backward")
351
- .setSessionCommand(command)
352
- .setIconResId(R.drawable.media3_icon_skip_back)
353
- .let { ButtonSpec(it, command) }
354
- }
355
- NotificationButton.JUMP_FORWARD -> {
356
- val command = SessionCommand(CUSTOM_ACTION_JUMP_FORWARD, Bundle())
357
- CommandButton.Builder()
358
- .setDisplayName("Jump Forward")
359
- .setSessionCommand(command)
360
- .setIconResId(R.drawable.media3_icon_skip_forward)
361
- .let { ButtonSpec(it, command) }
362
- }
363
- NotificationButton.FAVORITE -> {
364
- val heartIcon =
365
- if (favorited == true) CommandButton.ICON_HEART_FILLED
366
- else CommandButton.ICON_HEART_UNFILLED
367
- val displayName = if (favorited == true) "Remove from favorites" else "Add to favorites"
368
- val command = SessionCommand(CUSTOM_ACTION_FAVORITE, Bundle())
369
- ButtonSpec(
370
- CommandButton.Builder(heartIcon).setDisplayName(displayName).setSessionCommand(command),
371
- command,
372
- )
373
- }
358
+ RemoteButton.JUMP_BACKWARD -> SessionCommand(CUSTOM_ACTION_JUMP_BACKWARD, Bundle())
359
+ RemoteButton.JUMP_FORWARD -> SessionCommand(CUSTOM_ACTION_JUMP_FORWARD, Bundle())
360
+ RemoteButton.FAVORITE -> SessionCommand(CUSTOM_ACTION_FAVORITE, Bundle())
361
+ RemoteButton.SKIP_TO_PREVIOUS,
362
+ RemoteButton.SKIP_TO_NEXT -> null
374
363
  }
375
364
 
376
365
  /**
377
- * Assembles the notification CommandButtons (+ their session commands) for an already-derived
378
- * slot layout (see [deriveNotificationSlots] — the slots are pre-filtered by Capability).
366
+ * The Media3 player command behind a player-command-backed button; null for the custom ones. Both
367
+ * this and [customCommandFor] enumerate every button rather than using `else`, so adding one
368
+ * fails the build until it has been given an action on both sides.
379
369
  */
380
- private fun buildNotificationButtonPreferences(
381
- slots: List<SlottedButton>,
382
- searchAvailable: Boolean,
383
- favorited: Boolean?,
384
- ): Pair<SessionCommands, List<CommandButton>> {
385
- val sessionCommandsBuilder =
386
- MediaSession.ConnectionResult.DEFAULT_SESSION_AND_LIBRARY_COMMANDS.buildUpon()
387
-
388
- // Remove search commands if search is not configured
389
- if (!searchAvailable) {
390
- sessionCommandsBuilder.remove(SessionCommand.COMMAND_CODE_LIBRARY_SEARCH)
391
- sessionCommandsBuilder.remove(SessionCommand.COMMAND_CODE_LIBRARY_GET_SEARCH_RESULT)
370
+ private fun playerCommandFor(button: RemoteButton): Int? =
371
+ when (button) {
372
+ RemoteButton.SKIP_TO_PREVIOUS -> MediaPlayer.COMMAND_SEEK_TO_PREVIOUS
373
+ RemoteButton.SKIP_TO_NEXT -> MediaPlayer.COMMAND_SEEK_TO_NEXT
374
+ RemoteButton.JUMP_BACKWARD,
375
+ RemoteButton.JUMP_FORWARD,
376
+ RemoteButton.FAVORITE -> null
392
377
  }
393
378
 
394
- fun media3Slot(slot: NotificationSlot): Int =
395
- when (slot) {
396
- NotificationSlot.BACK -> CommandButton.SLOT_BACK
397
- NotificationSlot.FORWARD -> CommandButton.SLOT_FORWARD
398
- NotificationSlot.BACK_SECONDARY -> CommandButton.SLOT_BACK_SECONDARY
399
- NotificationSlot.FORWARD_SECONDARY -> CommandButton.SLOT_FORWARD_SECONDARY
400
- NotificationSlot.OVERFLOW -> CommandButton.SLOT_OVERFLOW
401
- }
402
-
403
- val buttons =
404
- slots.map { (button, slot) ->
405
- val spec = buttonFor(button, favorited)
406
- spec.sessionCommand?.let { sessionCommandsBuilder.add(it) }
407
- spec.builder.setSlots(media3Slot(slot)).build()
379
+ /** The single definition of a control's icon and label, shared by every surface. */
380
+ private fun buttonFor(button: RemoteButton, favorited: Boolean?): CommandButton.Builder {
381
+ val builder =
382
+ when (button) {
383
+ RemoteButton.SKIP_TO_PREVIOUS ->
384
+ CommandButton.Builder(CommandButton.ICON_PREVIOUS).setDisplayName("Previous")
385
+ RemoteButton.SKIP_TO_NEXT ->
386
+ CommandButton.Builder(CommandButton.ICON_NEXT).setDisplayName("Next")
387
+ RemoteButton.JUMP_BACKWARD ->
388
+ CommandButton.Builder(jumpBackwardIcon(currentBackwardJumpInterval))
389
+ .setDisplayName("Jump Backward")
390
+ RemoteButton.JUMP_FORWARD ->
391
+ CommandButton.Builder(jumpForwardIcon(currentForwardJumpInterval))
392
+ .setDisplayName("Jump Forward")
393
+ RemoteButton.FAVORITE ->
394
+ CommandButton.Builder(
395
+ if (favorited == true) CommandButton.ICON_HEART_FILLED
396
+ else CommandButton.ICON_HEART_UNFILLED
397
+ )
398
+ .setDisplayName(if (favorited == true) "Remove from favorites" else "Add to favorites")
408
399
  }
409
400
 
410
- Timber.Forest.d("Built notification button preferences: ${buttons.map { it.displayName }}")
411
- return Pair(sessionCommandsBuilder.build(), buttons)
401
+ return customCommandFor(button)?.let { builder.setSessionCommand(it) }
402
+ ?: playerCommandFor(button)?.let { builder.setPlayerCommand(it) }
403
+ ?: builder
412
404
  }
413
405
 
414
406
  /**
415
- * Builds player commands for the notification controller from the SAME slot derivation that
416
- * builds its buttons (so a shown button always has its command enabled — including buttons placed
417
- * in overflow, which the previous hand-rolled derivation missed). Slots are pre-filtered by
418
- * Capability; the button-less controls (play/pause, stop, seek) gate on the global capabilities.
407
+ * Assembles the CommandButtons for an already-derived slot layout (see [deriveButtonSlots] — the
408
+ * slots are pre-filtered by Capability).
409
+ *
410
+ * Each button gets its requested slot followed by OVERFLOW as a fallback. `slots` is an ordered
411
+ * preference list, and every surface caps how many buttons it renders — a head unit, the
412
+ * collapsed notification, the Android 13+ media controls all truncate differently. Without a
413
+ * fallback a button that loses its preferred slot disappears entirely instead of demoting.
419
414
  */
420
- private fun buildNotificationPlayerCommands(
421
- capabilities: PlayerCapabilities,
415
+ private fun buildButtonPreferences(
422
416
  slots: List<SlottedButton>,
423
- ): MediaPlayer.Commands {
424
- val builder = MediaSession.ConnectionResult.DEFAULT_PLAYER_COMMANDS.buildUpon()
425
-
426
- // Always remove direct media item commands to avoid dual-command confusion
427
- builder.remove(MediaPlayer.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM)
428
- builder.remove(MediaPlayer.COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM)
429
-
430
- val shown = slots.mapTo(mutableSetOf()) { it.button }
431
- if (NotificationButton.SKIP_TO_PREVIOUS !in shown) {
432
- builder.remove(MediaPlayer.COMMAND_SEEK_TO_PREVIOUS)
433
- }
434
- if (NotificationButton.SKIP_TO_NEXT !in shown) {
435
- builder.remove(MediaPlayer.COMMAND_SEEK_TO_NEXT)
436
- }
437
- if (NotificationButton.JUMP_BACKWARD !in shown) {
438
- builder.remove(MediaPlayer.COMMAND_SEEK_BACK)
439
- }
440
- if (NotificationButton.JUMP_FORWARD !in shown) {
441
- builder.remove(MediaPlayer.COMMAND_SEEK_FORWARD)
442
- }
443
-
444
- if (!capabilities.isEnabled(Control.PLAY_PAUSE)) {
445
- builder.remove(MediaPlayer.COMMAND_PLAY_PAUSE)
446
- }
447
- if (!capabilities.isEnabled(Control.STOP)) {
448
- builder.remove(MediaPlayer.COMMAND_STOP)
449
- }
450
- if (!capabilities.isEnabled(Control.SEEK_TO)) {
451
- builder.remove(MediaPlayer.COMMAND_SEEK_IN_CURRENT_MEDIA_ITEM)
417
+ favorited: Boolean?,
418
+ ): List<CommandButton> =
419
+ slots.map { (button, slot) ->
420
+ buttonFor(button, favorited).setSlots(*media3SlotsFor(slot)).build()
452
421
  }
453
-
454
- return builder.build()
455
- }
456
422
  }