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
44 changes: 44 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -375,6 +375,50 @@ it. It is never published, so it has to be asked for.
Upstream would close this with two lines — `def play(self, pos=None)` in `player/coordinator.py` and
the MPD backend. Coil needs no change when it lands: the ladder's first rung starts succeeding.

## The media session, and the four things media3 will not tell you

`PhonieboxPlayer` is a `SimpleBasePlayer` whose playback happens on the box, and `feature-media`'s
whole job is translating between the two. Four of media3's contracts are silent when broken — nothing
logs, nothing throws, a control simply does nothing — and all four were broken at some point:

- **A session the service was never handed shows no notification.**
`MediaNotificationManager.updateNotification` asks `isSessionAdded` before anything else, and media3
registers a session itself only when a controller binds with `MediaSessionService.SERVICE_INTERFACE`
(or the legacy browser action) or a media button intent arrives — `onBind` returns null for any
other action *without* consulting `onGetSession`. Coil's own binding is neither, so
`PhonieboxMediaService.onCreate` must call **`addSession`** itself. Without it no notification is
ever posted in either session mode, and the same early return calls
`maybeStopForegroundService(removeNotifications = true)`, which cancels media3's notification id.
That is why `MediaSessionBinder` now binds *with* the media3 action, too.
- **Notification ids have one owner each.** 1001 is media3's, and it cancels that id whenever it has
nothing to show; 1002 is the quiet "ready" notification automatic mode waits behind. They used to
share one id, which meant two writers with no ordering between them and a foreground service that
could end up with no notification at all. The quiet one follows `PlayerStatus.hasContent` — the
exact complement of media3's `shouldShowNotification` — so one of the two is up and never both.
- **A command's future must not be complete when it is returned.** `SimpleBasePlayer` shows its
optimistic placeholder state only while a returned future is pending: `updateStateForPendingOperation`
short-circuits on `isDone()` and calls `getState()` straight back, which still describes the box as
it was before the command was sent. `PhonieboxPlayer.send` therefore stays pending until the box has
been told and, where the outcome is published, until it says so — bounded, because `invalidateState`
is ignored while anything is pending.
- **`mediaItemIndex` is a timeline index, not a queue position**, and `C.INDEX_UNSET` means "media3
resolved this to nothing". Both are read in one place, `seekIntentFor`, whose KDoc carries the
reasoning; the short version is that a fallback timeline's only index means "the playing song", so
passing it to `playAt` restarts the album, and that "unset" only carries meaning when the timeline
really is the queue and shuffle is off.

Two smaller ones, same character: `COMMAND_GET_CURRENT_MEDIA_ITEM` has to be in the available
commands or the legacy session publishes no position and no duration (`PlayerWrapper` gates them on
it) and the system's media control shows no seek bar; and a timeline item's UID must come from the
queue row it occupies rather than from `playerstatus`, or a track change reads as a whole new playlist
and a queue holding one file twice can throw `"Duplicate MediaItemData UID in playlist"` out of
`getState()`.

`feature-media` has no Robolectric and should keep it that way: the load-bearing decisions live in
top-level functions — `timelineIndexFor`, `seekIntentFor`, `uidFor` — precisely because `getState()`
needs a `Looper` and they do not. Everything else here is service lifecycle and platform notification
behaviour, which only a device can tell you about.

## Stand-in cover art

A Phoniebox library is largely ripped CDs and home-made folders, so a great deal of it has no
Expand Down
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,21 @@ automatically from the `## [x.y.z]` heading matching `versionName` in `app/build

## [Unreleased]

## [1.1.1] - 2026-08-12

### Fixed
- **The playback controls now actually appear.** Coil is supposed to put the playing track in your
notification shade and on your lock screen, and it never did — the session was built correctly and
then never handed to the part of media3 that posts the notification, so nothing was ever shown in
either of the two control modes. In automatic mode the same fault could leave the quiet "ready"
notification cancelled behind it
- The controls also work properly now that they are there. The lock screen shows a **seek bar with
elapsed and remaining time**; play and pause **respond to the first tap** instead of appearing to
do nothing until the box got round to answering; ⏮ restarts the track part-way in and goes back a
track near the beginning, the way every other player does; a mute button sent from a car stereo
mutes rather than toggling, so it no longer unmutes an already-muted box; and tapping a track in a
car's playlist goes to that track instead of occasionally restarting the album

## [1.1.0] - 2026-08-11

### Added
Expand Down
4 changes: 2 additions & 2 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,8 @@ android {
applicationId = "app.coilforphoniebox"
minSdk = 26
targetSdk = 36
versionCode = 6
versionName = "1.1.0"
versionCode = 7
versionName = "1.1.1"
}

androidResources {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,8 @@ class FakePlayerRepository(
override suspend fun setVolume(level: Int): Result<Unit> = Result.success(Unit)
override suspend fun changeVolume(step: Int): Result<Unit> = Result.success(Unit)
override suspend fun toggleMute(): Result<Unit> = Result.success(Unit)

override suspend fun setMuted(muted: Boolean): Result<Unit> = Result.success(Unit)
override suspend fun startSleepTimer(minutes: Int): Result<Unit> = Result.success(Unit)
override suspend fun cancelSleepTimer(): Result<Unit> = Result.success(Unit)
override suspend fun refreshSleepTimer(): Result<Unit> = Result.success(Unit)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -433,7 +433,10 @@ class PlayerRepositoryImpl @Inject constructor(
transport.call(Commands.changeVolume(step)).unit()

override suspend fun toggleMute(): Result<Unit> =
transport.call(Commands.mute(!transport.currentVolume().muted)).unit()
setMuted(!transport.currentVolume().muted)

override suspend fun setMuted(muted: Boolean): Result<Unit> =
transport.call(Commands.mute(muted)).unit()

override suspend fun startSleepTimer(minutes: Int): Result<Unit> {
// `GenericTimerClass.start` logs "Ignoring start command" and returns when its timer
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,15 @@ interface PlayerRepository {
suspend fun changeVolume(step: Int): Result<Unit>
suspend fun toggleMute(): Result<Unit>

/**
* Mutes or unmutes outright, for a caller that knows which of the two it wants.
*
* The media session is one: a controller asking to mute sends "muted = true", not "the
* other one" — and answering that with [toggleMute] unmutes a box that was already muted.
* The box's own command takes an absolute state, so nothing is lost by saying so.
*/
suspend fun setMuted(muted: Boolean): Result<Unit>

/**
* Sets the timer that stops playback after [minutes], replacing a running one.
*
Expand Down
3 changes: 3 additions & 0 deletions fastlane/metadata/android/de-DE/changelogs/7.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
Die Wiedergabesteuerung von Coil erscheint jetzt in der Benachrichtigungsleiste und auf dem Sperrbildschirm. Bisher war sie dort überhaupt nicht zu sehen.

Und sie funktioniert richtig: mit Fortschrittsleiste und Zeitanzeige, mit Play und Pause, die schon beim ersten Tippen reagieren, und einer Zurück-Taste, die einen laufenden Titel neu startet.
3 changes: 3 additions & 0 deletions fastlane/metadata/android/en-US/changelogs/7.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
Coil's playback controls now appear in your notification shade and on the lock screen. They never showed up at all before this.

They work properly too: a progress bar with the time, play and pause that react to the first tap, and a previous button that starts the current track again part-way in.
3 changes: 3 additions & 0 deletions fastlane/metadata/android/es-ES/changelogs/7.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
Los controles de reproducción de Coil ya aparecen en el panel de notificaciones y en la pantalla de bloqueo: hasta ahora no se mostraban en absoluto.

Y funcionan de verdad: barra de progreso con el tiempo, reproducir y pausar que responden al primer toque, y un botón de anterior que reinicia la pista que ya ha empezado.
3 changes: 3 additions & 0 deletions fastlane/metadata/android/fr-FR/changelogs/7.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
Les commandes de lecture de Coil apparaissent enfin dans le volet des notifications et sur l'écran de verrouillage : elles ne s'y affichaient pas du tout jusqu'ici.

Et elles fonctionnent vraiment : barre de progression avec la durée, lecture et pause qui répondent au premier appui, et un bouton précédent qui reprend le titre en cours depuis le début.
3 changes: 3 additions & 0 deletions fastlane/metadata/android/nl-NL/changelogs/7.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
De afspeelknoppen van Coil verschijnen nu in het meldingenpaneel en op het vergrendelscherm. Tot nu toe waren ze daar helemaal niet te zien.

En ze werken ook echt: een voortgangsbalk met de tijd, afspelen en pauzeren die meteen reageren, en een vorige-knop die een al begonnen nummer opnieuw start.
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import android.content.ServiceConnection
import android.os.IBinder
import android.util.Log
import androidx.media3.common.util.UnstableApi
import androidx.media3.session.MediaSessionService
import app.coilforphoniebox.domain.model.SessionMode
import app.coilforphoniebox.domain.repository.SettingsRepository
import dagger.hilt.android.qualifiers.ApplicationContext
Expand Down Expand Up @@ -83,11 +84,18 @@ class MediaSessionBinder @Inject constructor(
if (wanted && allowed) bindNow() else unbindNow()
}

/**
* The action matters. `MediaSessionService.onBind` answers only its own service action and
* the legacy browser one, and returns null for anything else — so an actionless bind keeps
* the service alive without ever establishing a connection, which left the bound-client
* count here scoring a binding the platform had not made.
*/
private fun bindNow() {
if (bound) return
bound = runCatching {
context.bindService(
PhonieboxMediaService.serviceIntent(context),
PhonieboxMediaService.serviceIntent(context)
.setAction(MediaSessionService.SERVICE_INTERFACE),
connection,
Context.BIND_AUTO_CREATE,
)
Expand Down
Loading
Loading