Polymorphic load-media entry point
Playback starts from a lot of places: a search row, a library row, a playlist, an album, a shared link, a restored session. Each has its own item type and its own idea of what should be queued next. Given one entry point per caller, any policy that must hold for all of them will be missing from at least one.
Collapse them into a single function with two parameters that do the varying:
suspend fun <T> loadMediaItem(anyTrack: T, type: String, index: Int? = null) {
val track = when (anyTrack) {
is Track -> anyTrack
is SongEntity -> anyTrack.toTrack()
else -> return // unknown type: do nothing, touch nothing
}
// --- pre-enqueue policy: everything below this line assumes the item is allowed to play
if (track.isExplicit && !settings.explicitContentEnabled()) { showToast(Explicit); return }
songRepository.insertSong(track.toSongEntity())
clearMediaItems()
addMediaItem(track.toGenericMediaItem(), playWhenReady = type != RECOVER_TRACK_QUEUE)
when (type) {
SONG_CLICK, VIDEO_CLICK, SHARE -> getRelated(track.trackId) // seed from one item
PLAYLIST_CLICK, ALBUM_CLICK, RADIO_CLICK -> loadPlaylistOrAlbum(index)
}
}
The generic parameter normalizes content; the type discriminator routes strategy. They are
deliberately separate: two callers can pass the same item type and want different queues.
Traps
Policy gates must sit before the first player mutation, not after. Order is the whole point:
the content check returns before clearMediaItems(), so a blocked item leaves whatever is playing
untouched. Put the gate one line later and a rejected tap stops the music and shows a message — the
user loses their session to a rule that was supposed to change nothing.
Every side effect that must happen "whenever a track starts" belongs here, and only here. Recording the item locally, back-filling its duration, dedup against what is already queued: each of those, added at a call site, is a rule that holds on the screens someone remembered. Verify by counting callers — the function should have many, and no caller should reach the player directly:
grep -rn "loadMediaItem(" --include='*.kt' . # should list every playback origin
# in your UI module, nothing should reach the player directly — narrow the pattern to the
# player receiver so the handler's own delegating calls do not count as hits:
grep -rn "player\.addMediaItem(\|player\.setMediaItem(" --include='*.kt' <ui-module>/
(Quote the --include glob — unquoted, some shells expand it against the working directory and the
command fails before it searches anything.)
The type normalization needs a real else that does nothing. else -> return is not laziness;
it is the only correct answer for a caller that passed something the handler cannot represent.
Throwing turns a caller mistake into a stopped app, and a default of "treat it as a bare track" ships
an item with empty metadata into the queue.
A String discriminator has no exhaustiveness, and the failure is silent. A typo, or a new
origin added without a branch, falls through the when — the first item is enqueued and plays, and
the queue never fills, which reads as "the radio is broken" rather than "the constant is wrong".
Prefer a sealed type or an enum, and if it must stay a String, add an else branch that logs
loudly. Verify the set is closed by grepping the constants against the branches:
grep -rn "SONG_CLICK\|PLAYLIST_CLICK\|ALBUM_CLICK\|RADIO_CLICK\|SHARE\|RECOVER" --include='*.kt' .
Any constant that appears at a call site but not in the when is a dead origin.
Auto-play is derived from the discriminator, not passed as a flag. A restore path enqueues the
same item and must not start playing, so playWhenReady = type != RECOVER_TRACK_QUEUE keeps that
decision next to the branch that owns it. An extra boolean parameter would let a caller combine
"restore" with "play now", which is a state the app has no handling for.
The first item and the rest of the queue are seeded by different mechanisms. The tapped item is added immediately so audio starts now; the queue fills asynchronously behind it. That means the helper which adds items must self-start on the first one:
private fun addMediaItemNotSet(item: GenericMediaItem, index: Int? = null) {
index?.let { player.addMediaItem(it, item) } ?: player.addMediaItem(item)
if (player.mediaItemCount == 1) { player.prepare(); player.playWhenReady = true }
updateNextPreviousTrackAvailability()
}
Without that count check, an item added into an empty engine sits there prepared-but-idle and the tap appears to do nothing. With it applied unconditionally, every append restarts playback.
index means "position in the source", and one path re-reads it. For a playlist origin it is
which track the user tapped; the seeding path re-catalogs around it — moving the already-enqueued
item to that position and skipping it during the re-add, since it is already in the player. An
index that does not point at the item just enqueued shifts
the whole queue against the engine — the same failure the restore path guards against by putting the
track at the front when the saved queue does not contain it.
Being suspend is part of the contract. The gate reads a setting, and the normalization may hit
storage. Making it fire-and-forget internally would let a caller's own coroutine cancellation race
the gate, so the policy check would sometimes not complete before the item was enqueued.
Verifying it
Add a temporary log at the top of the entry point and exercise every origin the app has — each tap, each long-press menu, each share link, plus a cold restore. Every one should print exactly once. An origin that plays without printing is a call site that reached the player directly, and it is where the next forgotten rule will land.