Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,14 @@ An input event from an External surface — play, pause, next, previous, seek, f
A flag controlling whether a specific control is *available* on External surfaces. Disabling a Capability hides the corresponding control and prevents the matching **Remote command** from firing. Distinct from Remote command: a Capability is what's *configured*, a Remote command is the *event* fired when an available Capability is invoked.
*Avoid*: Permission, Feature flag, Control.

**Remote button**:
A button an External surface draws, which emits a **Remote command** when tapped — skip, jump, favorite. Distinct from a **Capability**: a Capability decides whether the button may exist at all, a Remote button is the thing rendered. Android only; CarPlay's now-playing buttons are configured separately.
*Avoid*: Notification button, player button, control button.

**Remote button layout**:
The arrangement of **Remote buttons** on Android, published once and honoured by every Android External surface — notification, Android Auto, and the Android 13+ system media controls. Has exactly three positions: `back` and `forward` either side of play/pause, and `overflow` for the rest. A layout describes the whole arrangement; omitting it derives one from **Capabilities**.
*Avoid*: Notification buttons, slots (a Media3 implementation term — `back`/`forward`/`overflow` are the domain names).

**Favorited**:
A boolean on a Track marking it as a user favorite. Toggled programmatically or via the heart button on an External surface. The library's domain vocabulary has no Rating concept.

Expand Down
22 changes: 11 additions & 11 deletions android/src/main/java/com/audiobrowser/model/PlayerUpdateOptions.kt
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,11 @@ package com.audiobrowser.model
import com.margelo.nitro.audiobrowser.AndroidOptions
import com.margelo.nitro.audiobrowser.AppKilledPlaybackBehavior
import com.margelo.nitro.audiobrowser.NativeUpdateOptions
import com.margelo.nitro.audiobrowser.NotificationButtonLayout
import com.margelo.nitro.audiobrowser.Options
import com.margelo.nitro.audiobrowser.PlayerCapabilities
import com.margelo.nitro.audiobrowser.RemoteButtonLayout
import com.margelo.nitro.audiobrowser.Variant_NullType_Double
import com.margelo.nitro.audiobrowser.Variant_NullType_NotificationButtonLayout
import com.margelo.nitro.audiobrowser.Variant_NullType_RemoteButtonLayout

/**
* Update options for the AudioBrowser that can be changed at runtime. These options control player
Expand Down Expand Up @@ -37,8 +37,8 @@ data class PlayerUpdateOptions(
playbackRate = null,
),

// Notification button layout (null = derive from capabilities)
var notificationButtons: NotificationButtonLayout? = null,
// Ordered button layout (null = derive from capabilities)
var remoteButtonLayout: RemoteButtonLayout? = null,

// Android-specific runtime options (all under android.* in JS)
var appKilledPlaybackBehavior: AppKilledPlaybackBehavior =
Expand Down Expand Up @@ -66,12 +66,12 @@ data class PlayerUpdateOptions(
// Update boolean options
androidOptions.skipSilence?.let { skipSilence = it }

// Handle notificationButtons - variant allows distinguishing undefined from null
androidOptions.notificationButtons?.let { variant ->
notificationButtons =
// Handle remoteButtonLayout - variant allows distinguishing undefined from null
androidOptions.remoteButtonLayout?.let { variant ->
remoteButtonLayout =
when (variant) {
is Variant_NullType_NotificationButtonLayout.First -> null
is Variant_NullType_NotificationButtonLayout.Second -> variant.value
is Variant_NullType_RemoteButtonLayout.First -> null
is Variant_NullType_RemoteButtonLayout.Second -> variant.value
}
}
}
Expand All @@ -84,8 +84,8 @@ data class PlayerUpdateOptions(
AndroidOptions(
appKilledPlaybackBehavior = appKilledPlaybackBehavior,
skipSilence = skipSilence,
notificationButtons =
notificationButtons?.let { Variant_NullType_NotificationButtonLayout.create(it) },
remoteButtonLayout =
remoteButtonLayout?.let { Variant_NullType_RemoteButtonLayout.create(it) },
),
forwardJumpInterval = forwardJumpInterval,
backwardJumpInterval = backwardJumpInterval,
Expand Down
169 changes: 111 additions & 58 deletions android/src/main/java/com/audiobrowser/player/CapabilityControls.kt
Original file line number Diff line number Diff line change
@@ -1,19 +1,22 @@
package com.audiobrowser.player

import com.margelo.nitro.audiobrowser.NotificationButton
import com.margelo.nitro.audiobrowser.NotificationButtonLayout
import com.margelo.nitro.audiobrowser.PlayerCapabilities
import com.margelo.nitro.audiobrowser.RemoteButton
import com.margelo.nitro.audiobrowser.RemoteButtonLayout

/**
* The pure decision core for Capability-gated controls: one rule for whether a control is available
* ([isEnabled]) and one derivation of the notification button layout ([deriveNotificationSlots]).
* MediaSessionCommandManager's three Media3 assemblies (external player commands, session button
* layout, notification commands/layout) all consume these decisions — previously each
* The pure decision core for Capabilities: one rule for whether a Capability is enabled
* ([isEnabled]) and one derivation of the button layout ([deriveButtonSlots]).
* MediaSessionCommandManager's Media3 assemblies all consume these decisions — previously each
* re-implemented them, and the copies had drifted.
*/

/** A user-facing control gated by a Capability. */
enum class Control {
/**
* A control that a Capability gates. Mirrors the [PlayerCapabilities] flags Android acts on —
* `shuffleMode`, `repeatMode` and `playbackRate` exist on the struct but only drive CarPlay, so
* they have no entry here.
*/
enum class Capability {
PLAY_PAUSE,
STOP,
SEEK_TO,
Expand All @@ -25,95 +28,145 @@ enum class Control {
}

/**
* Whether a Capability-gated control is available. Everything is enabled by default and only an
* explicit `false` disables — except FAVORITE, which is opt-in, and PLAY_PAUSE, which is one
* control on every surface and only goes away when both halves are disabled.
* Whether a Capability is enabled. Everything is enabled by default and only an explicit `false`
* disables — except FAVORITE, which is opt-in, and PLAY_PAUSE, which is one control on every
* surface and only goes away when both halves are disabled.
*/
fun PlayerCapabilities.isEnabled(control: Control): Boolean =
when (control) {
Control.PLAY_PAUSE -> !(play == false && pause == false)
Control.STOP -> stop != false
Control.SEEK_TO -> seekTo != false
Control.SKIP_TO_NEXT -> skipToNext != false
Control.SKIP_TO_PREVIOUS -> skipToPrevious != false
Control.JUMP_FORWARD -> jumpForward != false
Control.JUMP_BACKWARD -> jumpBackward != false
Control.FAVORITE -> favoriteEnabled
fun PlayerCapabilities.isEnabled(capability: Capability): Boolean =
when (capability) {
Capability.PLAY_PAUSE -> !(play == false && pause == false)
Capability.STOP -> stop != false
Capability.SEEK_TO -> seekTo != false
Capability.SKIP_TO_NEXT -> skipToNext != false
Capability.SKIP_TO_PREVIOUS -> skipToPrevious != false
Capability.JUMP_FORWARD -> jumpForward != false
Capability.JUMP_BACKWARD -> jumpBackward != false
Capability.FAVORITE -> favoriteEnabled
}

/** Notification slot, mapped to `CommandButton.SLOT_*` at Media3 assembly time. */
enum class NotificationSlot {
/**
* The three positions Android actually offers, mapped to `CommandButton.SLOT_*` at Media3 assembly
* time. There is no secondary pair: every system surface flattens the layout to back, forward and
* overflow, so a fourth or fifth position would have nowhere to render.
*
* Media3 does define SLOT_BACK_SECONDARY and SLOT_FORWARD_SECONDARY, but `DisplayConstraints`
* defaults both to **0 buttons** — only a controller that opts in with `setMaxButtonsForSlot` shows
* them, which no system surface does.
*/
enum class ButtonSlot {
BACK,
FORWARD,
BACK_SECONDARY,
FORWARD_SECONDARY,
OVERFLOW,
}

data class SlottedButton(val button: NotificationButton, val slot: NotificationSlot)
data class SlottedButton(val button: RemoteButton, val slot: ButtonSlot)

private val NotificationButton.control: Control
/** The Capability gating a button. */
private val RemoteButton.capability: Capability
get() =
when (this) {
NotificationButton.SKIP_TO_PREVIOUS -> Control.SKIP_TO_PREVIOUS
NotificationButton.SKIP_TO_NEXT -> Control.SKIP_TO_NEXT
NotificationButton.JUMP_BACKWARD -> Control.JUMP_BACKWARD
NotificationButton.JUMP_FORWARD -> Control.JUMP_FORWARD
NotificationButton.FAVORITE -> Control.FAVORITE
RemoteButton.SKIP_TO_PREVIOUS -> Capability.SKIP_TO_PREVIOUS
RemoteButton.SKIP_TO_NEXT -> Capability.SKIP_TO_NEXT
RemoteButton.JUMP_BACKWARD -> Capability.JUMP_BACKWARD
RemoteButton.JUMP_FORWARD -> Capability.JUMP_FORWARD
RemoteButton.FAVORITE -> Capability.FAVORITE
}

/** Whether [button]'s gating Capability allows it. */
fun PlayerCapabilities.allows(button: NotificationButton): Boolean = isEnabled(button.control)
fun PlayerCapabilities.allows(button: RemoteButton): Boolean = isEnabled(button.capability)

/**
* Whether two layouts describe the same arrangement.
*
* [RemoteButtonLayout] is a generated data class whose `overflow` is an `Array`, and Kotlin
* data-class equality compares arrays by *reference*. Plain `==` therefore reports "changed" for
* two identical layouts whenever overflow is set, because every options update carries a fresh
* array across the bridge — which would republish the layout to Android Auto on every unrelated
* `updateOptions` call.
*/
internal fun RemoteButtonLayout?.sameAs(other: RemoteButtonLayout?): Boolean =
if (this == null || other == null) {
this == null && other == null
} else {
back == other.back && forward == other.forward && overflow.contentEquals(other.overflow)
}

/**
* The single derivation of the notification button layout: an explicit [layout]'s slots filtered to
* allowed buttons, or — with no layout — the capability defaults: skip on the primary slots (jump
* falling back to a primary slot when its skip is disabled), jump on the secondary slots otherwise,
* favorite in overflow.
* The single derivation of the button layout.
*
* The two parameters answer different questions, and only one of them is authoritative:
* - [capabilities] — *may* this button exist? Admission.
* - [layout] — *where* does it go? Arrangement.
*
* **A layout can rearrange buttons but never add one.** Every entry is filtered through [allows]
* regardless of its position, so naming a button whose Capability is disabled does nothing —
* listing `FAVORITE` while `capabilities.favorite` is off leaves it off. This is the usual
* surprise, because JUMP_FORWARD and JUMP_BACKWARD default to disabled: a layout full of jump
* buttons silently produces none until those capabilities are turned on.
*
* With no [layout], [capabilities] does both jobs — it decides membership *and* infers positions:
* skip takes the primary positions, jump falls back into a primary position when its skip is
* disabled and otherwise sits in overflow, favorite in overflow.
*
* With a [layout], each field maps to its position — `back` to BACK, `forward` to FORWARD, every
* `overflow` entry to OVERFLOW in order — and disabled entries drop out. A dropped entry leaves its
* position empty rather than promoting anything into it: turning off one capability must not
* silently rearrange the row.
*
* A [layout] is all-or-nothing: every field is required, so it fully describes the arrangement and
* nothing is merged with the capability defaults. The bridge could not support a per-field merge
* anyway — Nitro maps both an omitted and a null enum field to Kotlin `null`, so native cannot tell
* "leave this empty" from "derive this one".
*
* [capabilities] has a second job outside this function: it also drives the player and session
* commands, which govern what a Bluetooth remote or headset can trigger. That is why placement is
* purely cosmetic here — a button left out of the layout disappears from every screen while still
* responding to a headset.
*
* @param capabilities What the player is allowed to do. Gates every button, in both modes.
* @param layout Explicit placement, or null to infer placement from [capabilities].
*/
fun deriveNotificationSlots(
fun deriveButtonSlots(
capabilities: PlayerCapabilities,
layout: NotificationButtonLayout?,
layout: RemoteButtonLayout?,
): List<SlottedButton> {
if (layout != null) {
return buildList {
layout.back?.let { add(SlottedButton(it, NotificationSlot.BACK)) }
layout.forward?.let { add(SlottedButton(it, NotificationSlot.FORWARD)) }
layout.backSecondary?.let { add(SlottedButton(it, NotificationSlot.BACK_SECONDARY)) }
layout.forwardSecondary?.let { add(SlottedButton(it, NotificationSlot.FORWARD_SECONDARY)) }
layout.overflow?.forEach { add(SlottedButton(it, NotificationSlot.OVERFLOW)) }
layout.back?.let { add(SlottedButton(it, ButtonSlot.BACK)) }
layout.forward?.let { add(SlottedButton(it, ButtonSlot.FORWARD)) }
layout.overflow.forEach { add(SlottedButton(it, ButtonSlot.OVERFLOW)) }
}
.filter { capabilities.allows(it.button) }
}

return buildList {
val skipPrevious = capabilities.isEnabled(Control.SKIP_TO_PREVIOUS)
val skipNext = capabilities.isEnabled(Control.SKIP_TO_NEXT)
val jumpBackward = capabilities.isEnabled(Control.JUMP_BACKWARD)
val jumpForward = capabilities.isEnabled(Control.JUMP_FORWARD)
val skipPrevious = capabilities.isEnabled(Capability.SKIP_TO_PREVIOUS)
val skipNext = capabilities.isEnabled(Capability.SKIP_TO_NEXT)
val jumpBackward = capabilities.isEnabled(Capability.JUMP_BACKWARD)
val jumpForward = capabilities.isEnabled(Capability.JUMP_FORWARD)

// Primary slots: skip, with jump falling back when its skip is disabled.
// Primary positions: skip, with jump falling back when its skip is disabled.
if (skipPrevious) {
add(SlottedButton(NotificationButton.SKIP_TO_PREVIOUS, NotificationSlot.BACK))
add(SlottedButton(RemoteButton.SKIP_TO_PREVIOUS, ButtonSlot.BACK))
} else if (jumpBackward) {
add(SlottedButton(NotificationButton.JUMP_BACKWARD, NotificationSlot.BACK))
add(SlottedButton(RemoteButton.JUMP_BACKWARD, ButtonSlot.BACK))
}
if (skipNext) {
add(SlottedButton(NotificationButton.SKIP_TO_NEXT, NotificationSlot.FORWARD))
add(SlottedButton(RemoteButton.SKIP_TO_NEXT, ButtonSlot.FORWARD))
} else if (jumpForward) {
add(SlottedButton(NotificationButton.JUMP_FORWARD, NotificationSlot.FORWARD))
add(SlottedButton(RemoteButton.JUMP_FORWARD, ButtonSlot.FORWARD))
}

// Jump moves to the secondary slots when skip holds the primary.
// Jump moves to overflow when skip holds the primary position.
if (skipPrevious && jumpBackward) {
add(SlottedButton(NotificationButton.JUMP_BACKWARD, NotificationSlot.BACK_SECONDARY))
add(SlottedButton(RemoteButton.JUMP_BACKWARD, ButtonSlot.OVERFLOW))
}
if (skipNext && jumpForward) {
add(SlottedButton(NotificationButton.JUMP_FORWARD, NotificationSlot.FORWARD_SECONDARY))
add(SlottedButton(RemoteButton.JUMP_FORWARD, ButtonSlot.OVERFLOW))
}

if (capabilities.isEnabled(Control.FAVORITE)) {
add(SlottedButton(NotificationButton.FAVORITE, NotificationSlot.OVERFLOW))
if (capabilities.isEnabled(Capability.FAVORITE)) {
add(SlottedButton(RemoteButton.FAVORITE, ButtonSlot.OVERFLOW))
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,8 @@ import com.margelo.nitro.audiobrowser.GateEvent
import com.margelo.nitro.audiobrowser.GateReason
import com.margelo.nitro.audiobrowser.MediaReference
import com.margelo.nitro.audiobrowser.NativeGateRequest
import com.margelo.nitro.audiobrowser.NotificationButtonLayout
import com.margelo.nitro.audiobrowser.PlayerCapabilities
import com.margelo.nitro.audiobrowser.RemoteButtonLayout
import com.margelo.nitro.audiobrowser.SearchParams
import com.margelo.nitro.audiobrowser.Track
import kotlinx.coroutines.CancellationException
Expand Down Expand Up @@ -160,16 +160,20 @@ class MediaSessionCallback(private val player: Player) :
fun updateMediaSession(
mediaSession: MediaSession,
capabilities: PlayerCapabilities,
notificationButtons: NotificationButtonLayout?,
remoteButtonLayout: RemoteButtonLayout?,
searchAvailable: Boolean,
forwardJumpInterval: Double,
backwardJumpInterval: Double,
) {
// Store as MediaLibrarySession for notifyChildrenChanged support
this.mediaLibrarySession = mediaSession as? MediaLibraryService.MediaLibrarySession
commandManager.updateMediaSession(
mediaSession,
capabilities,
notificationButtons,
remoteButtonLayout,
searchAvailable,
forwardJumpInterval,
backwardJumpInterval,
)
}

Expand Down Expand Up @@ -400,8 +404,8 @@ class MediaSessionCallback(private val player: Player) :
/**
* Converts tracks to MediaItems for browse delivery, routing http(s) artwork through the
* content:// provider so Android Auto can load it via the ArtworkContentProvider. Image-row
* tracks (a CarPlay-only rendering) are expanded into their items as regular grouped rows
* first — see [TrackFactory.expandImageRows].
* tracks (a CarPlay-only rendering) are expanded into their items as regular grouped rows first —
* see [TrackFactory.expandImageRows].
*/
private fun toMediaItems(tracks: List<Track>): List<MediaItem> {
val registry = player.browseArtworkRegistry
Expand Down
Loading
Loading