diff --git a/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/HomeApi.kt b/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/HomeApi.kt new file mode 100644 index 00000000..acfe8261 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/HomeApi.kt @@ -0,0 +1,17 @@ +package com.fabledsword.minstrel.api.endpoints + +import com.fabledsword.minstrel.models.wire.HomeIndexWire +import retrofit2.http.GET + +/** + * Retrofit interface for the Home discovery endpoint. Mirrors + * `flutter_client/lib/api/endpoints/home.dart` — just the ID-only + * `/api/home/index` variant. The Flutter port has a heavier + * `/api/home` (full embedded payload) too; we don't use it because + * the per-item hydration path (sync controller → Room → Flow) is + * the only one the native client needs. + */ +interface HomeApi { + @GET("api/home/index") + suspend fun getHomeIndex(): HomeIndexWire +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/home/data/HomeRepository.kt b/android/app/src/main/java/com/fabledsword/minstrel/home/data/HomeRepository.kt new file mode 100644 index 00000000..5edb5473 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/home/data/HomeRepository.kt @@ -0,0 +1,104 @@ +package com.fabledsword.minstrel.home.data + +import com.fabledsword.minstrel.api.endpoints.HomeApi +import com.fabledsword.minstrel.cache.db.dao.CachedAlbumDao +import com.fabledsword.minstrel.cache.db.dao.CachedArtistDao +import com.fabledsword.minstrel.cache.db.dao.CachedHomeIndexDao +import com.fabledsword.minstrel.cache.db.dao.CachedTrackDao +import com.fabledsword.minstrel.cache.db.entities.CachedHomeIndexEntity +import com.fabledsword.minstrel.library.data.toDomain +import com.fabledsword.minstrel.models.AlbumRef +import com.fabledsword.minstrel.models.ArtistRef +import com.fabledsword.minstrel.models.TrackRef +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import retrofit2.Retrofit +import retrofit2.create +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Cache-first reads for the Home screen. Per-section ID lists live in + * `cached_home_index`; entity rows live in `cached_albums` / + * `cached_artists` / `cached_tracks` and are observed via the + * Library DAOs. + * + * Sections are exposed as `Flow>`. Each emission joins + * the ID list against the per-entity DAO; rows that haven't been + * hydrated into Room yet are silently dropped from the emitted list + * (Phase 7+ adds a per-tile hydration queue that fills the gaps). + * + * `refreshIndex()` pulls `GET /api/home/index` and replaces each + * section in-place (delete-then-insert), so the Flow emissions + * propagate automatically. Errors surface to the caller. + */ +@Singleton +class HomeRepository @Inject constructor( + private val homeIndexDao: CachedHomeIndexDao, + private val albumDao: CachedAlbumDao, + private val artistDao: CachedArtistDao, + private val trackDao: CachedTrackDao, + retrofit: Retrofit, +) { + private val api: HomeApi = retrofit.create() + + fun observeRecentlyAddedAlbums(): Flow> = + homeIndexDao.observeBySection(SECTION_RECENTLY_ADDED_ALBUMS).map { hydrateAlbums(it) } + + fun observeRediscoverAlbums(): Flow> = + homeIndexDao.observeBySection(SECTION_REDISCOVER_ALBUMS).map { hydrateAlbums(it) } + + fun observeRediscoverArtists(): Flow> = + homeIndexDao.observeBySection(SECTION_REDISCOVER_ARTISTS).map { hydrateArtists(it) } + + fun observeMostPlayedTracks(): Flow> = + homeIndexDao.observeBySection(SECTION_MOST_PLAYED_TRACKS).map { hydrateTracks(it) } + + fun observeLastPlayedArtists(): Flow> = + homeIndexDao.observeBySection(SECTION_LAST_PLAYED_ARTISTS).map { hydrateArtists(it) } + + /** + * Pulls /api/home/index and replaces each section in cached_home_index. + * Caller (ViewModel) is responsible for catching network errors. + */ + suspend fun refreshIndex() { + val wire = api.getHomeIndex() + replaceSection(SECTION_RECENTLY_ADDED_ALBUMS, "album", wire.recentlyAddedAlbums) + replaceSection(SECTION_REDISCOVER_ALBUMS, "album", wire.rediscoverAlbums) + replaceSection(SECTION_REDISCOVER_ARTISTS, "artist", wire.rediscoverArtists) + replaceSection(SECTION_MOST_PLAYED_TRACKS, "track", wire.mostPlayedTracks) + replaceSection(SECTION_LAST_PLAYED_ARTISTS, "artist", wire.lastPlayedArtists) + } + + private suspend fun replaceSection(section: String, entityType: String, ids: List) { + homeIndexDao.deleteBySection(section) + if (ids.isEmpty()) return + homeIndexDao.upsertAll( + ids.mapIndexed { index, id -> + CachedHomeIndexEntity( + section = section, + position = index, + entityType = entityType, + entityId = id, + ) + }, + ) + } + + private suspend fun hydrateAlbums(rows: List): List = + rows.mapNotNull { albumDao.getById(it.entityId)?.toDomain() } + + private suspend fun hydrateArtists(rows: List): List = + rows.mapNotNull { artistDao.getById(it.entityId)?.toDomain() } + + private suspend fun hydrateTracks(rows: List): List = + rows.mapNotNull { trackDao.getById(it.entityId)?.toDomain() } + + companion object { + const val SECTION_RECENTLY_ADDED_ALBUMS = "recently_added_albums" + const val SECTION_REDISCOVER_ALBUMS = "rediscover_albums" + const val SECTION_REDISCOVER_ARTISTS = "rediscover_artists" + const val SECTION_MOST_PLAYED_TRACKS = "most_played_tracks" + const val SECTION_LAST_PLAYED_ARTISTS = "last_played_artists" + } +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/home/ui/HomeScreen.kt b/android/app/src/main/java/com/fabledsword/minstrel/home/ui/HomeScreen.kt new file mode 100644 index 00000000..459fb868 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/home/ui/HomeScreen.kt @@ -0,0 +1,359 @@ +package com.fabledsword.minstrel.home.ui + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation.NavHostController +import com.composables.icons.lucide.Lucide +import com.composables.icons.lucide.Music +import com.fabledsword.minstrel.home.data.HomeRepository +import com.fabledsword.minstrel.library.widgets.AlbumCard +import com.fabledsword.minstrel.library.widgets.ArtistCard +import com.fabledsword.minstrel.models.AlbumRef +import com.fabledsword.minstrel.models.ArtistRef +import com.fabledsword.minstrel.models.TrackRef +import com.fabledsword.minstrel.nav.AlbumDetail +import com.fabledsword.minstrel.nav.ArtistDetail +import com.fabledsword.minstrel.nav.Home +import com.fabledsword.minstrel.shared.widgets.EmptyState +import com.fabledsword.minstrel.shared.widgets.HorizontalScrollRow +import com.fabledsword.minstrel.shared.widgets.MainAppBarActions +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import javax.inject.Inject + +private const val SHARE_STOP_TIMEOUT_MS = 5_000L +private const val BOTTOM_PADDING_FOR_MINIPLAYER_DP = 140 + +// ─── State ─────────────────────────────────────────────────────────── + +data class HomeSections( + val recentlyAddedAlbums: List = emptyList(), + val rediscoverAlbums: List = emptyList(), + val rediscoverArtists: List = emptyList(), + val mostPlayedTracks: List = emptyList(), + val lastPlayedArtists: List = emptyList(), +) { + val isAllEmpty: Boolean + get() = recentlyAddedAlbums.isEmpty() && + rediscoverAlbums.isEmpty() && + rediscoverArtists.isEmpty() && + mostPlayedTracks.isEmpty() && + lastPlayedArtists.isEmpty() +} + +sealed interface HomeUiState { + data object Loading : HomeUiState + data object Empty : HomeUiState + data class Success(val sections: HomeSections) : HomeUiState + data class Error(val message: String) : HomeUiState +} + +// ─── ViewModel ─────────────────────────────────────────────────────── + +@HiltViewModel +class HomeViewModel @Inject constructor( + private val repository: HomeRepository, +) : ViewModel() { + + init { + // Fire-and-forget initial pull; the screen renders cached sections + // immediately and refreshes as the IDs land. Errors map to the + // Error state via the combine() chain's catch below. + viewModelScope.launch { + runCatching { repository.refreshIndex() } + } + } + + val uiState: StateFlow = + combine( + repository.observeRecentlyAddedAlbums(), + repository.observeRediscoverAlbums(), + repository.observeRediscoverArtists(), + repository.observeMostPlayedTracks(), + repository.observeLastPlayedArtists(), + ) { recentAlbums, rediscoverAlbums, rediscoverArtists, mostPlayedTracks, lastPlayedArtists -> + val sections = HomeSections( + recentlyAddedAlbums = recentAlbums, + rediscoverAlbums = rediscoverAlbums, + rediscoverArtists = rediscoverArtists, + mostPlayedTracks = mostPlayedTracks, + lastPlayedArtists = lastPlayedArtists, + ) + if (sections.isAllEmpty) HomeUiState.Empty else HomeUiState.Success(sections) + } + .catch { e -> emit(HomeUiState.Error(e.message ?: "Home load failed")) } + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(SHARE_STOP_TIMEOUT_MS), + initialValue = HomeUiState.Loading, + ) +} + +// ─── Screen ────────────────────────────────────────────────────────── + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun HomeScreen( + navController: NavHostController, + viewModel: HomeViewModel = hiltViewModel(), +) { + Scaffold( + modifier = Modifier.fillMaxSize(), + topBar = { + TopAppBar( + title = { Text("Minstrel") }, + actions = { + MainAppBarActions( + navController = navController, + currentRouteName = Home::class.qualifiedName, + ) + }, + ) + }, + ) { inner -> + val state by viewModel.uiState.collectAsStateWithLifecycle() + Box(modifier = Modifier.fillMaxSize().padding(inner)) { + when (val s = state) { + HomeUiState.Loading -> LoadingCentered() + HomeUiState.Empty -> EmptyState( + title = "Welcome to Minstrel", + body = "Nothing to show yet — scan a folder in your server settings, " + + "then come back here for system playlists and recommendations.", + ) + is HomeUiState.Error -> EmptyState( + title = "Couldn't load home", + body = s.message, + ) + is HomeUiState.Success -> HomeSuccessContent( + sections = s.sections, + onAlbumClick = { id -> navController.navigate(AlbumDetail(id)) }, + onArtistClick = { id -> navController.navigate(ArtistDetail(id)) }, + ) + } + } + } +} + +@Composable +private fun LoadingCentered() { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + CircularProgressIndicator(color = MaterialTheme.colorScheme.primary) + } +} + +@Composable +private fun HomeSuccessContent( + sections: HomeSections, + onAlbumClick: (String) -> Unit, + onArtistClick: (String) -> Unit, +) { + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(vertical = 8.dp), + ) { + // Playlists section is a Phase-7 deliverable; placeholder header + // keeps the row visible so users see what's coming. + item { + EmptySectionHeader( + title = "Playlists", + body = "System playlists land with Phase 7.", + ) + } + if (sections.recentlyAddedAlbums.isNotEmpty()) { + item { + AlbumsRow("Recently added", sections.recentlyAddedAlbums, onAlbumClick) + } + } + if (sections.rediscoverAlbums.isNotEmpty() || sections.rediscoverArtists.isNotEmpty()) { + item { + RediscoverBlock( + albums = sections.rediscoverAlbums, + artists = sections.rediscoverArtists, + onAlbumClick = onAlbumClick, + onArtistClick = onArtistClick, + ) + } + } + if (sections.mostPlayedTracks.isNotEmpty()) { + item { MostPlayedRow(sections.mostPlayedTracks, onAlbumClick) } + } + if (sections.lastPlayedArtists.isNotEmpty()) { + item { ArtistsRow("Last played", sections.lastPlayedArtists, onArtistClick) } + } + item { Spacer(Modifier.height(BOTTOM_PADDING_FOR_MINIPLAYER_DP.dp)) } + } +} + +/** + * Rediscover may have both an albums sub-row and an artists sub-row. When + * both are present the artist row gets an empty title so the section + * reads as a single visual block — matches the Flutter behavior. + */ +@Composable +private fun RediscoverBlock( + albums: List, + artists: List, + onAlbumClick: (String) -> Unit, + onArtistClick: (String) -> Unit, +) { + Column { + if (albums.isNotEmpty()) { + AlbumsRow("Rediscover", albums, onAlbumClick) + } + if (artists.isNotEmpty()) { + val title = if (albums.isEmpty()) "Rediscover" else "" + ArtistsRow(title, artists, onArtistClick) + } + } +} + +@Composable +private fun AlbumsRow( + title: String, + albums: List, + onAlbumClick: (String) -> Unit, +) { + HorizontalScrollRow(title = title) { + items(items = albums, key = { it.id }) { album -> + AlbumCard(album = album, onClick = { onAlbumClick(album.id) }) + } + } +} + +@Composable +private fun ArtistsRow( + title: String, + artists: List, + onArtistClick: (String) -> Unit, +) { + HorizontalScrollRow(title = title) { + items(items = artists, key = { it.id }) { artist -> + ArtistCard(artist = artist, onClick = { onArtistClick(artist.id) }) + } + } +} + +/** + * Most-played section. Renders compact track tiles (cover + title + artist). + * Tapping a tile navigates to that track's album for now; the proper + * "play the section as a queue" wiring lands once the player API + * exposes a play-list-from-IDs entry point in Phase 7. + */ +@Composable +private fun MostPlayedRow( + tracks: List, + onAlbumClick: (String) -> Unit, +) { + HorizontalScrollRow(title = "Most played") { + items(items = tracks, key = { it.id }) { track -> + CompactTrackTile(track = track, onClick = { onAlbumClick(track.albumId) }) + } + } +} + +@Composable +private fun CompactTrackTile( + track: TrackRef, + onClick: () -> Unit, +) { + Column( + modifier = Modifier + .width(140.dp) + .clickable(onClick = onClick), + ) { + Box( + modifier = Modifier + .size(140.dp) + .clip(RoundedCornerShape(6.dp)), + contentAlignment = Alignment.Center, + ) { + // No coverUrl on TrackRef — show the music glyph placeholder. + // The Flutter port snipes the album's cover via tile-provider + // hydration; matching that here is Phase 7+ work. + Icon( + imageVector = Lucide.Music, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(56.dp), + ) + } + Spacer(Modifier.height(8.dp)) + Text( + text = track.title, + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = track.artistName.ifEmpty { " " }, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } +} + +/** + * Placeholder header used for sections whose data layer hasn't landed yet + * (currently just Playlists). Smaller than EmptyState — the surrounding + * sections still have content, so this is more of a soft "TBD" marker. + */ +@Composable +private fun EmptySectionHeader(title: String, body: String) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + text = title, + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onBackground, + ) + Text( + text = body, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + diff --git a/android/app/src/main/java/com/fabledsword/minstrel/models/wire/HomeIndexWire.kt b/android/app/src/main/java/com/fabledsword/minstrel/models/wire/HomeIndexWire.kt new file mode 100644 index 00000000..8d0b4fa1 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/models/wire/HomeIndexWire.kt @@ -0,0 +1,28 @@ +package com.fabledsword.minstrel.models.wire + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * Wire shape of `GET /api/home/index` — five flat slices of entity-ID + * strings, one per Home section. Mirrors + * `flutter_client/lib/models/home_index.dart` (and the server's + * `internal/api/types.go HomeIndexPayload`). + * + * Section name implies entity type; no per-entry type tag is needed: + * - recentlyAddedAlbums → album + * - rediscoverAlbums → album + * - rediscoverArtists → artist + * - mostPlayedTracks → track + * - lastPlayedArtists → artist + * + * All slices default to empty so missing keys don't throw on decode. + */ +@Serializable +data class HomeIndexWire( + @SerialName("recently_added_albums") val recentlyAddedAlbums: List = emptyList(), + @SerialName("rediscover_albums") val rediscoverAlbums: List = emptyList(), + @SerialName("rediscover_artists") val rediscoverArtists: List = emptyList(), + @SerialName("most_played_tracks") val mostPlayedTracks: List = emptyList(), + @SerialName("last_played_artists") val lastPlayedArtists: List = emptyList(), +) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/nav/MinstrelNavGraph.kt b/android/app/src/main/java/com/fabledsword/minstrel/nav/MinstrelNavGraph.kt index 7070e03e..c9a25bf6 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/nav/MinstrelNavGraph.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/nav/MinstrelNavGraph.kt @@ -10,6 +10,7 @@ import androidx.navigation.NavHostController import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.toRoute +import com.fabledsword.minstrel.home.ui.HomeScreen import com.fabledsword.minstrel.library.ui.LibraryScreen import com.fabledsword.minstrel.player.ui.NowPlayingScreen import com.fabledsword.minstrel.shared.widgets.EmptyState @@ -31,7 +32,7 @@ fun MinstrelNavGraph( val expandPlayer = { navController.navigate(NowPlaying) } NavHost( navController = navController, - startDestination = Library, + startDestination = Home, modifier = modifier.fillMaxSize(), ) { inShellTopLevel(navController, expandPlayer) @@ -46,7 +47,7 @@ private fun NavGraphBuilder.inShellTopLevel( ) { composable { ShellScaffold(onExpandPlayer = expandPlayer) { - ComingSoon("Home", "Lands in the next design-drift sub-task.") + HomeScreen(navController = navController) } } composable { diff --git a/android/app/src/main/java/com/fabledsword/minstrel/shared/widgets/HorizontalScrollRow.kt b/android/app/src/main/java/com/fabledsword/minstrel/shared/widgets/HorizontalScrollRow.kt new file mode 100644 index 00000000..d1e4642f --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/shared/widgets/HorizontalScrollRow.kt @@ -0,0 +1,50 @@ +package com.fabledsword.minstrel.shared.widgets + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp + +/** + * Labeled horizontal-scroll section used throughout the Home screen. + * Mirrors `flutter_client/lib/library/widgets/horizontal_scroll_row.dart` + * — a Fraunces section title at 16dp gutter, then a horizontal LazyRow + * with the same gutter and 8dp inter-item spacing. + * + * Pass empty `title` to render a continuation row directly under a + * previously-titled one (used by Rediscover when it has both album and + * artist sub-rows, and by chunked sections like Recently-added). + * + * The `content` lambda receives a `LazyListScope` so callers use + * `items(list, key = ...) { ... }` — preserves lazy item rendering. + */ +@Composable +fun HorizontalScrollRow( + title: String, + modifier: Modifier = Modifier, + content: LazyListScope.() -> Unit, +) { + Column(modifier = modifier.fillMaxWidth()) { + if (title.isNotEmpty()) { + Text( + text = title, + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onBackground, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + ) + } + LazyRow( + contentPadding = PaddingValues(horizontal = 16.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + content = content, + ) + } +}