Release: web UX overhaul + Android native port + server polish #59

Merged
bvandeusen merged 298 commits from dev into main 2026-06-01 16:11:21 -04:00
3 changed files with 34 additions and 17 deletions
Showing only changes of commit 487400fab6 - Show all commits
@@ -69,6 +69,7 @@ import com.fabledsword.minstrel.nav.ArtistDetail
import com.fabledsword.minstrel.nav.Home
import com.fabledsword.minstrel.nav.PlaylistDetail
import com.fabledsword.minstrel.playlists.data.PlaylistsRepository
import com.fabledsword.minstrel.playlists.data.toPlayableTrackRefs
import com.fabledsword.minstrel.playlists.widgets.OfflinePoolCard
import com.fabledsword.minstrel.playlists.widgets.PlaylistCard
import com.fabledsword.minstrel.playlists.widgets.PlaylistPlaceholderCard
@@ -274,20 +275,10 @@ class HomeViewModel @Inject constructor(
)
return@launch
}
val tracks = detail.tracks.mapNotNull { row ->
row.trackId?.let { trackId ->
TrackRef(
id = trackId,
title = row.title,
albumId = row.albumId.orEmpty(),
albumTitle = row.albumTitle,
artistId = row.artistId.orEmpty(),
artistName = row.artistName,
durationSec = row.durationSec,
streamUrl = row.streamUrl.orEmpty(),
)
}
}
// Shared with PlaylistDetailViewModel.play - filters out
// unplayable rows (missing trackId or empty streamUrl) so the
// queue can't end up with tracks Media3 silently rejects.
val tracks = detail.tracks.toPlayableTrackRefs()
if (tracks.isEmpty()) {
poolMessages.trySend("Mix isn't ready yet - try again in a moment")
return@launch
@@ -12,6 +12,7 @@ import com.fabledsword.minstrel.cache.db.entities.CachedPlaylistTrackEntity
import com.fabledsword.minstrel.cache.mutations.MutationQueue
import com.fabledsword.minstrel.models.PlaylistRef
import com.fabledsword.minstrel.models.PlaylistTrackRef
import com.fabledsword.minstrel.models.TrackRef
import com.fabledsword.minstrel.models.wire.PlaylistDetailWire
import com.fabledsword.minstrel.models.wire.PlaylistTrackWire
import com.fabledsword.minstrel.models.wire.PlaylistWire
@@ -204,6 +205,31 @@ data class PlaylistDetailRef(
val tracks: List<PlaylistTrackRef>,
)
/**
* Convert each [PlaylistTrackRef] to a player-ready [TrackRef], dropping
* any row that isn't actually playable (trackId missing OR streamUrl
* missing). The two call sites (PlaylistDetailViewModel.play and
* HomeViewModel.playPlaylist) used to hand-roll this conversion and
* differed on whether they applied the filter, which let unplayable
* tracks reach `player.setQueue` and silently no-op inside Media3
* (setUri("") never errors). Centralise both the conversion AND the
* defensive filter so the queue is always playable.
*/
fun List<PlaylistTrackRef>.toPlayableTrackRefs(): List<TrackRef> =
filter { it.isAvailable && !it.streamUrl.isNullOrEmpty() }
.map { row ->
TrackRef(
id = row.trackId.orEmpty(),
title = row.title,
albumId = row.albumId.orEmpty(),
artistId = row.artistId.orEmpty(),
albumTitle = row.albumTitle,
artistName = row.artistName,
durationSec = row.durationSec,
streamUrl = row.streamUrl.orEmpty(),
)
}
// ── Mappers (internal — keep Room + wire types out of the UI layer) ──
private fun CachedPlaylistEntity.toDomain(): PlaylistRef =
@@ -69,6 +69,7 @@ import com.fabledsword.minstrel.player.PlayerController
import com.fabledsword.minstrel.likes.data.LikesRepository
import com.fabledsword.minstrel.playlists.data.PlaylistDetailRef
import com.fabledsword.minstrel.playlists.data.PlaylistsRepository
import com.fabledsword.minstrel.playlists.data.toPlayableTrackRefs
import com.fabledsword.minstrel.shared.formatDuration
import com.fabledsword.minstrel.shared.widgets.TrackRow
import com.fabledsword.minstrel.shared.widgets.LikeButton
@@ -223,9 +224,8 @@ class PlaylistDetailViewModel @Inject constructor(
/** Play the available tracks starting at [startTrackId] (or first available). */
fun play(tracks: List<PlaylistTrackRef>, startTrackId: String?) {
val playable = tracks.filter { it.isAvailable && !it.streamUrl.isNullOrEmpty() }
if (playable.isEmpty()) return
val refs = playable.map { it.toTrackRef() }
val refs = tracks.toPlayableTrackRefs()
if (refs.isEmpty()) return
val startIndex = refs.indexOfFirst { it.id == startTrackId }.coerceAtLeast(0)
player.setQueue(refs, initialIndex = startIndex, source = "playlist:$playlistId")
}