diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/PlayerController.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/PlayerController.kt index 813f5747..31dac4b2 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/player/PlayerController.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/PlayerController.kt @@ -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, + 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) { diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/ui/NowPlayingScreen.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/ui/NowPlayingScreen.kt index f59d6d19..8002842b 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/player/ui/NowPlayingScreen.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/ui/NowPlayingScreen.kt @@ -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,