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
63 changes: 63 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -305,13 +305,76 @@ Protocol details, distilled from `jukebox/multitimer.py` and `components/timers/
therefore has nothing in the last-value cache, which is why the timer sheet asks `get_state` once
when it opens, and why the countdown is interpolated locally from `remainingSecondsAt`, the same
way the progress bar interpolates elapsed time.
- **The last-value cache is a trap on this one topic, and only this one.** For every other topic the
cached message is current — `playerstatus` was published 250 ms ago. Here it is the message that
*set* the timer, carrying the `remaining_seconds` of that moment and no timestamp to date it by, so
a new subscriber that believes it restarts the countdown from the top. Sending the app to the
background closes the session (§8.3) and coming back opens a new one, which is how a 30-minute
timer used to read 30:00 again on every return. Two things in `PhonieboxSession` stop that:
`handshake` asks `get_state` on every connection, and a timer publish arriving within
`TIMER_REPLAY_WINDOW_MILLIS` of the SUB socket opening triggers a `get_state` instead of being
taken at face value — a replay and a change made that same second are indistinguishable, and asking
is right for both. Within a live session the replay after ZMQ's own silent TCP reconnect is already
dropped by `ZmqStatusSubscriber`'s raw-string compare; only a *new* subscriber sees it.

The player screen's shuffle and repeat icons moved into one "playback options" menu alongside the
timer: three mode toggles flanking the transport controls is more than that screen can carry. The
menu's button is tinted when any of the three is active, and a running timer also shows a
"Stops in …" line under the transport row, because a countdown to silence should not be hidden
behind a tap.

## The queue, and why skipping into it is stepped

The player shows one song; `playerstatus` carries only `pos` and `playlistlength`. The queue behind
it comes from **`player.ctrl.playlistinfo`**, which is a real RPC on both `future3` branches even
though the box's own web UI never calls it and `src/webapp/src/commands/index.js` therefore omits
it. It is never published, so it has to be asked for.

- **Asked once per queue change, never on a timer.** `PlayerRepositoryImpl.resolveQueueWhenItChanges`
refetches only when the cached queue can no longer be the one playing — `playlistlength` moved, or
`playerstatus.file` is not in it. A plain track change fits the cached queue and costs nothing, so
an album of twenty tracks is one RPC rather than twenty. There is a one-second debounce first,
because a queue change is usually a card tap and the box is still finishing the card handling on
that same sequential socket (§6).
- **`QueueEntry.title` falls back to the file name.** Most of a Phoniebox library is untagged rips,
so a missing title tag is the common case, not an edge one.
- **There is no command to play a queue position.** See `docs/protocol-notes.md`, "There is no way
to play a queue position", for the full list of routes ruled out. The one that looks like an
answer and is not is `play_single`: it clears the queue first, so using it to reach chapter seven
would leave the box silent when chapter seven ended. `PlayerRepository.playAt` is therefore a
ladder — try `play(pos=…)`, then walk with `next`/`prev`:
- **The probe is free.** An unpatched box rejects the kwarg in the plugin's signature *before the
body runs*, and the RPC server turns that into an error reply, so nothing about playback changes
for having asked. The answer is remembered per box **in memory only** — a box gets updated, and
a persisted "no" would outlive its reason. Only an `RpcErrorException` counts as "no"; a timeout
means the box is off and says nothing about its software.
- **The walk is closed-loop, not a blind burst.** It fires the gap, then reads
`playlistPosition` back and closes what is left one step at a time. That is what makes a command
marked `retryable = false` safe here: a step that went missing shows up as a position that did
not move, and nothing is ever resent blind.
- **Pause first, if it was playing.** From `pause` the box's `next` takes `mpd_client.next()`
rather than its stopped-state branch, and MPD may hold the pause across it — which would make
the whole walk silent. Unverified on hardware; if it does not hold, the walk is audible but
still correct. The original state is restored under `NonCancellable`, so cancelling half way
cannot leave the box paused.
- **Never past the last index**, which would run the box's own `end_of_playlist_next_action`.
- **Refused while shuffle is on.** MPD's `next` with `random` enabled goes to a *random* song, not
`pos + 1`, so no number of steps arrives anywhere in particular. Coil says so rather than
turning the user's shuffle off behind their back. The restriction lifts by itself on a box with
`play(pos=…)`, which ignores `random`.
- **The media session gets the real timeline**, and `timelineIndexFor` is the guard that makes it
safe. The queue and the status arrive independently, so there is a window where the box has moved
to another album and the cached queue still describes the old one. `SimpleBasePlayer` **throws** if
`currentMediaItemIndex` falls outside the playlist, and an index that merely points at the wrong
track puts another album's title on the lock screen — so both are checked, and either miss falls
back to the single-item timeline. It is a top-level function with its own test because `getState()`
needs a `Looper` and this does not.
- **No cover art per queued row**, in the sheet or the timeline: a cover is an RPC each on the shared
socket. Only the playing item has one, and it is the one item built from `playerstatus`.

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.

## 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
21 changes: 21 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,27 @@ automatically from the `## [x.y.z]` heading matching `versionName` in `app/build

## [Unreleased]

## [1.1.0] - 2026-08-11

### Added
- **The player now shows what the box has queued, and you can skip straight to a track in it.** A new
playlist button beside the transport controls opens the whole list with the playing track marked;
tapping a row goes there and leaves the rest of the album queued behind it, so an audio play carries
on into the next chapter instead of stopping. Reaching chapter seven no longer means tapping ⏭ six
times. The same list now reaches the lock screen, Android Auto and Assistant, which used to see a
single song with nothing around it
- The Phoniebox has no command for jumping to a playlist position, so Coil walks the list one track
at a time where it has to — the row it is heading for shows its progress and can be called off. This
is skipped entirely on a box whose software can jump directly, and a future Phoniebox release could
make it instant. Two limits worth knowing: it needs shuffle off, and Coil says so rather than
changing that setting for you

### Fixed
- **A running sleep timer no longer starts its countdown over when you come back to the app.** The
timer on the box was always right, but the "Stops in …" line under the transport controls read the
full 30 minutes again every time Coil returned to the foreground. Coil now asks the box what is
actually left of the timer whenever it connects, so what you see counts down once

## [1.0.1] - 2026-08-06

### 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 = 5
versionName = "1.0.1"
versionCode = 6
versionName = "1.1.0"
}

androidResources {
Expand Down
51 changes: 49 additions & 2 deletions app/src/main/kotlin/app/coilforphoniebox/ui/player/PlayerScreen.kt
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import androidx.compose.material.icons.automirrored.rounded.VolumeUp
import androidx.compose.material.icons.rounded.Bedtime
import androidx.compose.material.icons.rounded.Pause
import androidx.compose.material.icons.rounded.PlayArrow
import androidx.compose.material.icons.rounded.QueueMusic
import androidx.compose.material.icons.rounded.Repeat
import androidx.compose.material.icons.rounded.RepeatOne
import androidx.compose.material.icons.rounded.Shuffle
Expand Down Expand Up @@ -92,7 +93,26 @@ fun PlayerScreen(
val scrub by viewModel.scrubPosition.collectAsStateWithLifecycle()
val volumeTarget by viewModel.volumeTarget.collectAsStateWithLifecycle()
val timerRemaining by viewModel.sleepTimerRemaining.collectAsStateWithLifecycle()
val queue by viewModel.queue.collectAsStateWithLifecycle()
var timerSheetOpen by remember { mutableStateOf(false) }
var queueSheetOpen by remember { mutableStateOf(false) }

if (queueSheetOpen) {
QueueSheet(
queue = queue,
currentPosition = state.status.playlistPosition,
onJumpTo = viewModel::jumpTo,
onCancelJump = viewModel::cancelJump,
onRetry = viewModel::refreshQueue,
onDismiss = {
queueSheetOpen = false
// Closing the sheet abandons a walk in progress: it is the only place its
// progress is visible, and a jump nobody can see arriving or cancel is worse
// than one that stops where it is.
viewModel.cancelJump()
},
)
}

if (timerSheetOpen) {
SleepTimerSheet(
Expand All @@ -116,6 +136,10 @@ fun PlayerScreen(
timerSheetOpen = true
}

// No RPC here, unlike the timer: the queue is already resolved and kept in step in the
// background, once per queue change rather than once per opening (§6).
val openQueue = { queueSheetOpen = true }

BoxWithConstraints(modifier.fillMaxSize()) {
// Two panes on a large screen, and also on any window that is merely wider than it is
// tall: a phone on its side has width to spare and no height at all, which is the same
Expand All @@ -135,6 +159,7 @@ fun PlayerScreen(
timerRemaining = timerRemaining,
viewModel = viewModel,
onOpenTimer = openTimer,
onOpenQueue = openQueue,
)
} else {
CompactPlayer(
Expand All @@ -151,6 +176,7 @@ fun PlayerScreen(
timerRemaining = timerRemaining,
viewModel = viewModel,
onOpenTimer = openTimer,
onOpenQueue = openQueue,
)
}
}
Expand All @@ -166,6 +192,7 @@ private fun CompactPlayer(
timerRemaining: Int?,
viewModel: PlayerViewModel,
onOpenTimer: () -> Unit,
onOpenQueue: () -> Unit,
) {
Column(
modifier = Modifier
Expand Down Expand Up @@ -197,6 +224,7 @@ private fun CompactPlayer(
timerRemaining = timerRemaining,
viewModel = viewModel,
onOpenTimer = onOpenTimer,
onOpenQueue = onOpenQueue,
)

Spacer(Modifier.height(24.dp))
Expand All @@ -220,6 +248,7 @@ private fun WidePlayer(
timerRemaining: Int?,
viewModel: PlayerViewModel,
onOpenTimer: () -> Unit,
onOpenQueue: () -> Unit,
) {
Row(
modifier = Modifier
Expand Down Expand Up @@ -258,6 +287,7 @@ private fun WidePlayer(
timerRemaining = timerRemaining,
viewModel = viewModel,
onOpenTimer = onOpenTimer,
onOpenQueue = onOpenQueue,
)
}
}
Expand Down Expand Up @@ -327,6 +357,7 @@ private fun ColumnScope.PlayerControls(
timerRemaining: Int?,
viewModel: PlayerViewModel,
onOpenTimer: () -> Unit,
onOpenQueue: () -> Unit,
) {
ProgressRow(
elapsedSeconds = scrub?.toDouble() ?: state.status.elapsedSeconds,
Expand All @@ -343,12 +374,14 @@ private fun ColumnScope.PlayerControls(
repeat = state.status.repeat,
timerRunning = state.sleepTimer.running,
anyOptionActive = state.anyOptionActive,
queueLength = state.status.playlistLength,
onToggle = viewModel::toggle,
onNext = viewModel::next,
onPrevious = viewModel::previous,
onShuffle = viewModel::toggleShuffle,
onRepeat = viewModel::cycleRepeat,
onOpenTimer = onOpenTimer,
onOpenQueue = onOpenQueue,
)

// Only while a timer is running: a countdown to something stopping is worth a line of its
Expand Down Expand Up @@ -508,12 +541,14 @@ private fun TransportRow(
repeat: RepeatMode,
timerRunning: Boolean,
anyOptionActive: Boolean,
queueLength: Int,
onToggle: () -> Unit,
onNext: () -> Unit,
onPrevious: () -> Unit,
onShuffle: () -> Unit,
onRepeat: () -> Unit,
onOpenTimer: () -> Unit,
onOpenQueue: () -> Unit,
) {
Row(
modifier = Modifier.fillMaxWidth(),
Expand Down Expand Up @@ -563,8 +598,20 @@ private fun TransportRow(
)
}

// Balances the options button on the other side; an IconButton's own footprint.
Spacer(Modifier.size(48.dp))
// Balances the options button on the other side, whichever of the two it holds — both
// are an IconButton's own 48 dp, so the play button stays centred either way. There is
// nothing to open for a web radio stream or a single track, and `playlistLength` is how
// the box says so.
if (queueLength > 1) {
IconButton(onClick = onOpenQueue) {
Icon(
imageVector = Icons.Rounded.QueueMusic,
contentDescription = stringResource(R.string.action_show_queue),
)
}
} else {
Spacer(Modifier.size(48.dp))
}
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,10 @@ import app.coilforphoniebox.domain.model.Box
import app.coilforphoniebox.domain.model.ConnectionState
import app.coilforphoniebox.domain.model.Favorite
import app.coilforphoniebox.domain.model.FavoriteType
import app.coilforphoniebox.domain.model.JumpOutcome
import app.coilforphoniebox.domain.model.PlayTarget
import app.coilforphoniebox.domain.model.PlayerStatus
import app.coilforphoniebox.domain.model.QueueEntry
import app.coilforphoniebox.domain.model.RepeatMode
import app.coilforphoniebox.domain.model.SleepTimerStatus
import app.coilforphoniebox.domain.model.VolumeStatus
Expand All @@ -20,6 +22,7 @@ import app.coilforphoniebox.ui.UiMessage
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.Job
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
Expand Down Expand Up @@ -161,6 +164,87 @@ class PlayerViewModel @Inject constructor(
)
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), State())

/**
* What the queue sheet shows.
*
* A [StateFlow] of its own rather than fields on [State], and deliberately so: reading the
* queue takes a round trip, and anything folded into that `combine` holds up the title,
* progress and controls until it has emitted — the trap the cover lookup is kept out of for
* the same reason. Nothing here can delay the player.
*/
data class QueueState(
val entries: List<QueueEntry> = emptyList(),
/** An answer is on its way, so an empty [entries] is not yet a failure. */
val loading: Boolean = false,
/**
* Position the box is being sent to, while it is being sent there.
*
* On a box that cannot jump outright this is a walk of one `next` per track, which takes
* a visible moment — so the row being aimed at says so and can be called off. Null when
* nothing is in flight.
*/
val jumpTarget: Int? = null,
) {
/** Nothing to show and nothing coming: the box was asked and did not answer. */
val failed: Boolean get() = entries.isEmpty() && !loading
}

private val _jumpTarget = MutableStateFlow<Int?>(null)
private var jumpJob: Job? = null

val queue: StateFlow<QueueState> = combine(
player.queue,
player.queueLoading,
_jumpTarget,
) { entries, loading, jumpTarget -> QueueState(entries, loading, jumpTarget) }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), QueueState())

/**
* Sends the box to queue position [position].
*
* The box has no command for this, so the repository may have to walk the queue there one
* track at a time — which is why this tracks a target the UI can show and cancel, and why
* three different outcomes are worth telling the user apart.
*/
fun jumpTo(position: Int) {
jumpJob?.cancel()
_jumpTarget.value = position
jumpJob = viewModelScope.launch {
try {
player.playAt(position)
.onFailure { messageChannel.emit(UiMessage(commandError())) }
.onSuccess { outcome ->
when (outcome) {
// Arriving is what was asked for; saying so would be noise.
JumpOutcome.Arrived -> Unit
JumpOutcome.BlockedByShuffle ->
messageChannel.emit(UiMessage(R.string.queue_needs_shuffle_off))
// The box is playing *something*, just not this. Reporting success
// would leave the highlighted row lying about where it is.
is JumpOutcome.Incomplete ->
messageChannel.emit(UiMessage(R.string.queue_jump_incomplete))
}
}
} finally {
// Guarded, because a newer jump may already have claimed the field.
if (_jumpTarget.value == position) _jumpTarget.value = null
}
}
}

/** Abandons a walk in progress. The box keeps playing wherever it got to. */
fun cancelJump() {
jumpJob?.cancel()
_jumpTarget.value = null
}

/** For a sheet showing a list that failed to arrive; the ordinary case needs no help. */
fun refreshQueue() {
viewModelScope.launch {
player.refreshQueue().onFailure { messageChannel.emit(UiMessage(commandError())) }
}
}

/**
* Seconds left on the timer, recomputed once a second while one is running.
*
Expand Down
Loading
Loading