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
15 changes: 12 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -375,11 +375,11 @@ 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
## The media session, and the five 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:
whole job is translating between the two. Five of media3's contracts are silent when broken — nothing
logs, nothing throws, a control simply does nothing — and all five were broken at some point:

- **A session the service was never handed shows no notification.**
`MediaNotificationManager.updateNotification` asks `isSessionAdded` before anything else, and media3
Expand All @@ -401,6 +401,15 @@ logs, nothing throws, a control simply does nothing — and all four were broken
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.
- **A null metadata title is not an empty line.** `DefaultMediaNotificationProvider` reads
`displayTitle ?: title` and posts the notification with no content title at all when both are
absent, and the platform fills that hole with its own "<app> is running" — which is what the lock
screen showed for every untagged rip, artist and cover art beside it perfectly correct. It reads
as *stale* metadata rather than missing metadata, because within one untagged album the metadata
genuinely never changes: same null title, same albumartist, same folder cover, so
`SimpleBasePlayer` has nothing to report and the same wrong line stays up track after track. The
playing item's title therefore goes through `PlayerStatus.displayTitle`, which falls back to the
file name the way `QueueParser` and `LibraryTrack.displayTitle` already do.
- **`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
Expand Down
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,16 @@ automatically from the `## [x.y.z]` heading matching `versionName` in `app/build

## [Unreleased]

## [1.2.1] - 2026-08-27

### Fixed
- **The lock screen names the track again.** For a file with no title tag — which is most of a
ripped-CD library — the controls in the shade and on the lock screen read "Coil is running"
instead of the track, and kept reading it as the box moved through the album, even though the
artist and the cover beside it were right all along. An untagged track is now named by its file
name there, the same way it already was in the queue and in the library, and the player screen
and the mini player name it that way too instead of saying "Unknown title"

## [1.2.0] - 2026-08-19

### 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 = 8
versionName = "1.2.0"
versionCode = 9
versionName = "1.2.1"
}

androidResources {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ fun MiniPlayer(
Spacer(Modifier.size(12.dp))
Column(Modifier.weight(1f)) {
Text(
text = status.title ?: stringResource(R.string.player_no_title),
text = status.displayTitle ?: stringResource(R.string.player_no_title),
style = MaterialTheme.typography.bodyMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -303,7 +303,7 @@ private fun TitleBlock(
Row(verticalAlignment = Alignment.Top) {
Column(Modifier.weight(1f)) {
Text(
text = state.status.title
text = state.status.displayTitle
?: if (state.status.hasContent) {
stringResource(R.string.player_no_title)
} else {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -83,10 +83,11 @@ class PlayerViewModel @Inject constructor(
/** What stand-in artwork is keyed on while this is playing — see [coverNameOf]. */
val coverName: String? get() = coverNameOf(status)

/** Track name as shown in the menu; the tags first, the file name as a fallback. */
val trackLabel: String? get() = status.file?.let { file ->
status.title?.takeIf { it.isNotBlank() } ?: file.substringAfterLast('/')
}
/**
* Track name as shown in the menu — [PlayerStatus.displayTitle], but only for something
* that can actually be starred, which is a file and not a title on its own.
*/
val trackLabel: String? get() = if (status.file != null) status.displayTitle else null
}

private val messageChannel = MutableSharedFlow<UiMessage>(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,19 @@ data class PlayerStatus(
/** Folder the current song sits in, used to offer "save this as a favourite". */
val folder: String? get() = file?.substringBeforeLast('/', missingDelimiterValue = "")?.ifEmpty { null }

/**
* What to call the current track. Never blank while a file is loaded, which the media session
* depends on: media3 posts a notification with no content title for a null one, and the
* platform fills that hole with its own "<app> is running".
*
* The tag first, then the file name — the same fallback `QueueParser` and
* [LibraryTrack.displayTitle] apply, and for the same reason: most of a Phoniebox library is
* untagged rips, so a missing title tag is the common case rather than the odd one.
*/
val displayTitle: String?
get() = title?.takeIf { it.isNotBlank() }
?: file?.substringAfterLast('/')?.takeIf { it.isNotBlank() }

companion object {
val Idle = PlayerStatus()
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
package app.coilforphoniebox.domain.model

import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test

class PlayerStatusTest {

@Test
fun `the title tag wins when the box sends one`() {
val status = PlayerStatus(
title = "Kapitel 3",
file = "Petronella/03 - Kapitel 3.mp3",
)
assertEquals("Kapitel 3", status.displayTitle)
}

/**
* The case that put "Coil is running" on the lock screen: most of a Phoniebox library is
* untagged rips, and a null title reaches media3 as a notification with no content title,
* which the platform fills in with its own text.
*/
@Test
fun `an untagged file falls back to its name`() {
val status = PlayerStatus(file = "Petronella/03 - Kapitel 3.mp3")
assertEquals("03 - Kapitel 3.mp3", status.displayTitle)
}

/** A blank tag is the same as no tag; the parsers map both to null, but not every source does. */
@Test
fun `a blank title falls back too`() {
val status = PlayerStatus(title = " ", file = "Petronella/03 - Kapitel 3.mp3")
assertEquals("03 - Kapitel 3.mp3", status.displayTitle)
}

/** A file at the library root has no folder to strip off. */
@Test
fun `a file with no folder is its own name`() {
assertEquals("kapitel.mp3", PlayerStatus(file = "kapitel.mp3").displayTitle)
}

/** A stream has no file name, so there is nothing to fall back to but the tag. */
@Test
fun `a trailing slash leaves nothing to fall back to`() {
assertNull(PlayerStatus(file = "http://stream.example/").displayTitle)
}

/** An idle box names nothing, which is what every caller's own placeholder is for. */
@Test
fun `an idle box has no title`() {
assertNull(PlayerStatus.Idle.displayTitle)
}
}
1 change: 1 addition & 0 deletions fastlane/metadata/android/de-DE/changelogs/9.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Ein Titel ohne Titel-Tag wird auf dem Sperrbildschirm und in der Benachrichtigung jetzt nach seiner Datei benannt. Dort stand vorher „Coil wird ausgeführt“, und das blieb stehen, während die Box durch das Album lief. Der Player und die Mini-Leiste benennen ihn genauso.
1 change: 1 addition & 0 deletions fastlane/metadata/android/en-US/changelogs/9.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
A track with no title tag is now named by its file name on the lock screen and in the notification shade. Those controls used to read "Coil is running" instead, and kept reading it as the box moved through the album. The player screen and the mini player name such a track the same way.
1 change: 1 addition & 0 deletions fastlane/metadata/android/es-ES/changelogs/9.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Una pista sin etiqueta de título ahora se nombra por su archivo en la pantalla de bloqueo y en las notificaciones. Antes, esos controles mostraban el nombre de la aplicación, y seguían así mientras la caja avanzaba por el álbum. La pantalla del reproductor y la minibarra la nombran igual.
1 change: 1 addition & 0 deletions fastlane/metadata/android/fr-FR/changelogs/9.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Une piste sans balise de titre est désormais nommée d'après son fichier sur l'écran de verrouillage et dans les notifications. Ces commandes affichaient le nom de l'application à la place, et le gardaient pendant que la boîte parcourait l'album. L'écran du lecteur et la mini-barre la nomment de même.
1 change: 1 addition & 0 deletions fastlane/metadata/android/nl-NL/changelogs/9.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Een nummer zonder titeltag krijgt op het vergrendelscherm en in de meldingen nu de naam van zijn bestand. Daar stond eerder de naam van de app, en die bleef staan terwijl de box door het album liep. Het spelerscherm en de mini-balk noemen het net zo.
Original file line number Diff line number Diff line change
Expand Up @@ -343,7 +343,10 @@ class PhonieboxPlayer(
?: C.TIME_UNSET

val metadata = MediaMetadata.Builder()
.setTitle(status.title)
// Not `status.title`: the box leaves the tag out for an untagged rip, and a null title
// is not an empty line on the lock screen — media3 posts a notification with no
// content title and the platform substitutes "<app> is running" for it.
.setTitle(status.displayTitle)
.setArtist(status.artist)
.setAlbumTitle(status.album)
// With more than one box, the lock screen has to make clear which device is
Expand Down
Loading