fix(android): satisfy detekt — ReturnCount + LongMethod refactors
android / Build + lint + test (push) Failing after 3m20s

Three rule trips from the playback-errors + scrubber commits:

PlayerController.startRadio (4 returns → 2): extract the mid-queue
append branch into appendRadioToQueue(). startRadio just does the
guard checks and dispatches; the helper handles the cursor trim +
addMediaItems. Behavior identical.

PlayerController.onPlaybackStateChanged (4 returns → 2): extract
the duration check + zero-duration error emission + skip logic
into handleZeroDurationIfNeeded(). The listener stays compact (one
return for non-READY, one for repeat-evaluation guard); the helper
owns the failure path.

NowPlayingScreen.ScrubberRow (63 lines → ~50): extract the custom
track Box block into a ScrubTrack(fraction, accent) composable.
The Slider's `track` lambda becomes a one-line call. Pixel output
is identical.
This commit is contained in:
2026-06-02 11:45:45 -04:00
parent de61305fde
commit 337fce83a1
2 changed files with 72 additions and 51 deletions
@@ -200,21 +200,29 @@ class PlayerController @Inject constructor(
val tracks = radio.seed(trackId)
if (tracks.isEmpty()) return
val source = "radio:$trackId"
if (controller.mediaItemCount == 0) {
setQueue(tracks, initialIndex = 0, source = source)
return
} else {
appendRadioToQueue(controller, tracks, trackId, source)
}
}
/**
* Mid-queue radio insert. The current track keeps playing; every
* upcoming item is removed; the radio results are appended after.
* Drops the seed from the appended list when it matches the
* currently-playing track so it doesn't immediately repeat.
*/
private fun appendRadioToQueue(
controller: MediaController,
tracks: List<TrackRef>,
seedTrackId: String,
source: String,
) {
val currentIdx = controller.currentMediaItemIndex
val currentTrack = queueRefs.getOrNull(currentIdx)
// Server returns seed at index 0. Drop the duplicate when the
// seed is the currently playing track — otherwise append the
// full list so the current track is followed by the seed +
// recommendations.
val toAppend = if (currentTrack?.id == trackId) tracks.drop(1) else tracks
val toAppend = if (currentTrack?.id == seedTrackId) tracks.drop(1) else tracks
if (toAppend.isEmpty()) return
val nextIdx = currentIdx + 1
if (nextIdx < controller.mediaItemCount) {
controller.removeMediaItems(nextIdx, controller.mediaItemCount)
@@ -223,6 +231,31 @@ class PlayerController @Inject constructor(
controller.addMediaItems(toAppend.map { it.toMediaItem(source) })
}
/**
* When a STATE_READY transition for a freshly-loaded item reports
* a zero / TIME_UNSET duration, fire a `zero_duration` error event
* and advance past the dead track. Otherwise no-op.
*/
private fun handleZeroDurationIfNeeded(controller: MediaController, idx: Int) {
val current = queueRefs.getOrNull(idx) ?: return
val duration = controller.duration
val isZeroDuration = duration <= 0L || duration == androidx.media3.common.C.TIME_UNSET
if (!isZeroDuration) return
playbackErrorEventsChannel.trySend(
PlaybackErrorEvent(
trackId = current.id,
kind = "zero_duration",
title = current.title.ifEmpty { "Track" },
detail = "duration=$duration",
),
)
if (controller.hasNextMediaItem()) {
controller.seekToNextMediaItem()
} else {
controller.stop()
}
}
// ── Internal: async connect + Listener-driven UI state sync ──────────
private suspend fun connectAndObserve() {
@@ -271,26 +304,7 @@ class PlayerController @Inject constructor(
val idx = controller.currentMediaItemIndex
if (idx == lastEvaluatedItemIndex) return
lastEvaluatedItemIndex = idx
val current = queueRefs.getOrNull(idx) ?: return
val duration = controller.duration
if (duration > 0L && duration != androidx.media3.common.C.TIME_UNSET) return
// Zero-duration / unset-duration track: file decoded
// to nothing playable. Fail fast — fire an error
// event for the reporter (snackbar + server log) and
// advance so the user doesn't sit on the dead track.
playbackErrorEventsChannel.trySend(
PlaybackErrorEvent(
trackId = current.id,
kind = "zero_duration",
title = current.title.ifEmpty { "Track" },
detail = "duration=$duration",
),
)
if (controller.hasNextMediaItem()) {
controller.seekToNextMediaItem()
} else {
controller.stop()
}
handleZeroDurationIfNeeded(controller, idx)
}
override fun onEvents(player: Player, events: Player.Events) {
@@ -491,29 +491,8 @@ private fun ScrubberRow(positionMs: Long, durationMs: Long, onSeek: (Long) -> Un
.background(accent),
)
},
// Custom 4dp rounded track. M3's default Track is 16dp tall
// and reads as a heavy pill rather than a measurement line;
// making the thumb visibly taller than the track (14dp thumb
// on a 4dp bar) restores the "handle on a string" cue your
// eye reads as "tool, draggable." Also drops M3's stop
// indicator dot, which the web scrubber doesn't have.
track = { _ ->
Box(modifier = Modifier.fillMaxWidth().height(SCRUB_TRACK_HEIGHT_DP)) {
Box(
modifier = Modifier
.matchParentSize()
.clip(RoundedCornerShape(SCRUB_TRACK_CORNER_DP))
.background(MaterialTheme.colorScheme.surfaceVariant),
)
Box(
modifier = Modifier
.fillMaxWidth(fraction)
.fillMaxHeight()
.clip(RoundedCornerShape(SCRUB_TRACK_CORNER_DP))
.background(accent),
)
}
},
// Custom 4dp rounded track — see ScrubTrack for rationale.
track = { _ -> ScrubTrack(fraction = fraction, accent = accent) },
)
Row(
modifier = Modifier.fillMaxWidth(),
@@ -532,6 +511,34 @@ private fun ScrubberRow(positionMs: Long, durationMs: Long, onSeek: (Long) -> Un
}
}
/**
* Custom 4dp rounded scrubber track. M3's default Track is 16dp tall
* and reads as a heavy pill rather than a measurement line; making
* the thumb visibly taller than the track (14dp thumb on a 4dp bar)
* restores the "handle on a string" cue your eye reads as "tool,
* draggable." Also drops M3's stop indicator dot, which the web
* scrubber doesn't have. Extracted so [ScrubberRow] stays under
* detekt's LongMethod ceiling.
*/
@Composable
private fun ScrubTrack(fraction: Float, accent: Color) {
Box(modifier = Modifier.fillMaxWidth().height(SCRUB_TRACK_HEIGHT_DP)) {
Box(
modifier = Modifier
.matchParentSize()
.clip(RoundedCornerShape(SCRUB_TRACK_CORNER_DP))
.background(MaterialTheme.colorScheme.surfaceVariant),
)
Box(
modifier = Modifier
.fillMaxWidth(fraction)
.fillMaxHeight()
.clip(RoundedCornerShape(SCRUB_TRACK_CORNER_DP))
.background(accent),
)
}
}
@Composable
private fun TransportRow(
isPlaying: Boolean,