Phase 3: Subsonic browsing API + real library UI

Replaces Phase 2's sample-data placeholders with real Navidrome
browsing: artists, albums, songs, and search, per the Phase 2+
roadmap's Phase 3.

- SubsonicApi grows from just ping to getArtists/getArtist/
  getAlbumList2/getAlbum/search3, with DTOs split per-domain
  (ArtistModels/AlbumModels/SongModels/SearchModels) and mapped to
  plain domain models (data/model) by the new LibraryRepository
- Coil wired in for cover art, sharing the same signed OkHttpClient
  as Retrofit via a Hilt EntryPoint (SubsonicRequestInterceptor signs
  cover-art requests identically to every other Subsonic call) -
  Artwork() falls back to the Phase 2 placeholder icon when no cover
  art id is known
- ui/library replaces the Phase 2 placeholders: HomeScreen now shows
  real Recently Added/Played and a random "Made For You" row,
  ArtistsScreen is the real Library tab, ArtistDetailScreen and
  AlbumDetailScreen are new drill-down screens (typed nav routes,
  ViewModels resolve their id via SavedStateHandle.toRoute()), and
  SearchScreen does debounced search3 across artists/albums/songs
- TrackRow gained a `circular` option so it can double as an artist
  row in the Library list, not just a track row

First real test coverage in the repo: LibraryRepositoryTest (DTO to
domain-model mapping against MockWebServer) and
SubsonicRequestInterceptorTest (rewritten URL + signed query params).
CredentialsStore/ServerRepository tests are deliberately still out of
scope - CredentialsStore's Tink/Android Keystore usage isn't testable
in a plain JVM unit test without Robolectric, which felt like a
bigger side quest than this phase called for.

Coil pinned to 3.3.0 rather than the newer 3.6.x latest: newer Coil
requires Kotlin 2.4+, which conflicts with this project's Kotlin
2.2.10; 3.3.0 was the last release built against Kotlin 2.2.x.

Verified end-to-end against a real Navidrome server on-device: Home
rows, artist list/detail, album detail, and search all load real data
and real cover art.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
christopher
2026-09-17 03:59:53 -04:00
co-authored by Claude Sonnet 5
parent a07868b688
commit 08b3a9a0df
37 changed files with 1616 additions and 146 deletions
+13
View File
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="DeviceTable">
<option name="columnSorters">
<list>
<ColumnSorterState>
<option name="column" value="Name" />
<option name="order" value="ASCENDING" />
</ColumnSorterState>
</list>
</option>
</component>
</project>
+7
View File
@@ -68,7 +68,14 @@ dependencies {
implementation(libs.androidx.datastore.preferences)
implementation(libs.tink.android)
// Cover art loading (shares the signed OkHttpClient from NetworkModule)
implementation(libs.coil.compose)
implementation(libs.coil.network.okhttp)
testImplementation(libs.junit)
testImplementation(libs.okhttp.mockwebserver)
testImplementation(libs.mockito.core)
testImplementation(libs.kotlinx.coroutines.test)
androidTestImplementation(platform(libs.androidx.compose.bom))
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
androidTestImplementation(libs.androidx.espresso.core)
@@ -1,7 +1,27 @@
package com.InfernalAquatics.deepwave
import android.app.Application
import android.content.Context
import coil3.ImageLoader
import coil3.SingletonImageLoader
import coil3.network.okhttp.OkHttpNetworkFetcherFactory
import com.InfernalAquatics.deepwave.di.OkHttpClientEntryPoint
import dagger.hilt.EntryPoints
import dagger.hilt.android.HiltAndroidApp
@HiltAndroidApp
class DeepwaveApplication : Application()
class DeepwaveApplication : Application(), SingletonImageLoader.Factory {
/**
* Reuses the same signed OkHttpClient as Retrofit so cover-art requests get the
* Subsonic auth params from [com.InfernalAquatics.deepwave.data.network.SubsonicRequestInterceptor]
* too. Pulled via an EntryPoint rather than field injection since Coil constructs
* this outside Hilt's own injection points.
*/
override fun newImageLoader(context: Context): ImageLoader {
val okHttpClient = EntryPoints.get(this, OkHttpClientEntryPoint::class.java).okHttpClient()
return ImageLoader.Builder(context)
.components { add(OkHttpNetworkFetcherFactory(callFactory = { okHttpClient })) }
.build()
}
}
@@ -0,0 +1,32 @@
package com.InfernalAquatics.deepwave.data.model
/** Domain models the UI/repositories deal in — mapped from Subsonic DTOs, never exposed directly. */
data class Artist(
val id: String,
val name: String,
val albumCount: Int,
val coverArtId: String?,
)
data class Album(
val id: String,
val name: String,
val artistName: String,
val artistId: String?,
val coverArtId: String?,
val songCount: Int,
val year: Int?,
)
data class Song(
val id: String,
val title: String,
val albumName: String?,
val artistName: String?,
val albumId: String?,
val artistId: String?,
val coverArtId: String?,
val track: Int?,
val durationSeconds: Int?,
)
@@ -0,0 +1,12 @@
package com.InfernalAquatics.deepwave.data.network
import com.InfernalAquatics.deepwave.di.PLACEHOLDER_BASE_URL
/**
* Builds a cover-art request URL for Coil. Just like Retrofit's placeholder base URL,
* [SubsonicRequestInterceptor] rewrites this onto the real server and signs it — Coil's
* ImageLoader shares the same OkHttpClient (see DeepwaveApplication), so this resolves
* identically to every other Subsonic request.
*/
fun coverArtUrl(coverArtId: String?, size: Int = 300): String? =
coverArtId?.let { "${PLACEHOLDER_BASE_URL}rest/getCoverArt.view?id=$it&size=$size" }
@@ -1,7 +1,13 @@
package com.InfernalAquatics.deepwave.data.network
import com.InfernalAquatics.deepwave.data.network.model.GetAlbumListResponse
import com.InfernalAquatics.deepwave.data.network.model.GetAlbumResponse
import com.InfernalAquatics.deepwave.data.network.model.GetArtistResponse
import com.InfernalAquatics.deepwave.data.network.model.GetArtistsResponse
import com.InfernalAquatics.deepwave.data.network.model.PingResponse
import com.InfernalAquatics.deepwave.data.network.model.SearchResult3Response
import retrofit2.http.GET
import retrofit2.http.Query
/**
* Subsonic API surface implemented by Navidrome. The base URL is a placeholder
@@ -12,4 +18,28 @@ import retrofit2.http.GET
interface SubsonicApi {
@GET("rest/ping")
suspend fun ping(): PingResponse
@GET("rest/getArtists")
suspend fun getArtists(): GetArtistsResponse
@GET("rest/getArtist")
suspend fun getArtist(@Query("id") id: String): GetArtistResponse
@GET("rest/getAlbumList2")
suspend fun getAlbumList2(
@Query("type") type: String,
@Query("size") size: Int = 20,
@Query("offset") offset: Int = 0,
): GetAlbumListResponse
@GET("rest/getAlbum")
suspend fun getAlbum(@Query("id") id: String): GetAlbumResponse
@GET("rest/search3")
suspend fun search3(
@Query("query") query: String,
@Query("artistCount") artistCount: Int = 10,
@Query("albumCount") albumCount: Int = 10,
@Query("songCount") songCount: Int = 10,
): SearchResult3Response
}
@@ -0,0 +1,56 @@
package com.InfernalAquatics.deepwave.data.network.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class AlbumSummaryDto(
val id: String,
val name: String,
val artist: String? = null,
val artistId: String? = null,
val coverArt: String? = null,
val songCount: Int = 0,
val duration: Int = 0,
val year: Int? = null,
)
@Serializable
data class GetAlbumListResponse(
@SerialName("subsonic-response") val subsonicResponse: GetAlbumListBody,
)
@Serializable
data class GetAlbumListBody(
val albumList2: AlbumListDto? = null,
val error: SubsonicError? = null,
)
@Serializable
data class AlbumListDto(
val album: List<AlbumSummaryDto> = emptyList(),
)
@Serializable
data class GetAlbumResponse(
@SerialName("subsonic-response") val subsonicResponse: GetAlbumBody,
)
@Serializable
data class GetAlbumBody(
val album: AlbumDetailDto? = null,
val error: SubsonicError? = null,
)
@Serializable
data class AlbumDetailDto(
val id: String,
val name: String,
val artist: String? = null,
val artistId: String? = null,
val coverArt: String? = null,
val songCount: Int = 0,
val duration: Int = 0,
val year: Int? = null,
val song: List<SongDto> = emptyList(),
)
@@ -0,0 +1,54 @@
package com.InfernalAquatics.deepwave.data.network.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class ArtistSummaryDto(
val id: String,
val name: String,
val albumCount: Int = 0,
val coverArt: String? = null,
)
@Serializable
data class GetArtistsResponse(
@SerialName("subsonic-response") val subsonicResponse: GetArtistsBody,
)
@Serializable
data class GetArtistsBody(
val artists: ArtistsIndexDto? = null,
val error: SubsonicError? = null,
)
@Serializable
data class ArtistsIndexDto(
val index: List<ArtistIndexEntryDto> = emptyList(),
)
@Serializable
data class ArtistIndexEntryDto(
val name: String,
val artist: List<ArtistSummaryDto> = emptyList(),
)
@Serializable
data class GetArtistResponse(
@SerialName("subsonic-response") val subsonicResponse: GetArtistBody,
)
@Serializable
data class GetArtistBody(
val artist: ArtistDetailDto? = null,
val error: SubsonicError? = null,
)
@Serializable
data class ArtistDetailDto(
val id: String,
val name: String,
val albumCount: Int = 0,
val coverArt: String? = null,
val album: List<AlbumSummaryDto> = emptyList(),
)
@@ -0,0 +1,22 @@
package com.InfernalAquatics.deepwave.data.network.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class SearchResult3Response(
@SerialName("subsonic-response") val subsonicResponse: SearchResult3Body,
)
@Serializable
data class SearchResult3Body(
val searchResult3: SearchResult3Dto? = null,
val error: SubsonicError? = null,
)
@Serializable
data class SearchResult3Dto(
val artist: List<ArtistSummaryDto> = emptyList(),
val album: List<AlbumSummaryDto> = emptyList(),
val song: List<SongDto> = emptyList(),
)
@@ -0,0 +1,16 @@
package com.InfernalAquatics.deepwave.data.network.model
import kotlinx.serialization.Serializable
@Serializable
data class SongDto(
val id: String,
val title: String,
val album: String? = null,
val artist: String? = null,
val albumId: String? = null,
val artistId: String? = null,
val coverArt: String? = null,
val track: Int? = null,
val duration: Int? = null,
)
@@ -0,0 +1,102 @@
package com.InfernalAquatics.deepwave.data.repository
import com.InfernalAquatics.deepwave.data.model.Album
import com.InfernalAquatics.deepwave.data.model.Artist
import com.InfernalAquatics.deepwave.data.model.Song
import com.InfernalAquatics.deepwave.data.network.SubsonicApi
import com.InfernalAquatics.deepwave.data.network.model.AlbumDetailDto
import com.InfernalAquatics.deepwave.data.network.model.AlbumSummaryDto
import com.InfernalAquatics.deepwave.data.network.model.ArtistSummaryDto
import com.InfernalAquatics.deepwave.data.network.model.SongDto
import javax.inject.Inject
import javax.inject.Singleton
data class SearchResults(
val artists: List<Artist>,
val albums: List<Album>,
val songs: List<Song>,
)
/** Subsonic album-list row types accepted by [LibraryRepository.getAlbumList]. */
object AlbumListType {
const val NEWEST = "newest"
const val RECENT = "recent"
const val RANDOM = "random"
const val FREQUENT = "frequent"
}
/** Read-only library browsing: artists, albums, songs, search. Maps DTOs to domain models. */
@Singleton
class LibraryRepository @Inject constructor(
private val subsonicApi: SubsonicApi,
) {
suspend fun getArtists(): List<Artist> =
subsonicApi.getArtists().subsonicResponse.artists?.index.orEmpty()
.flatMap { it.artist }
.map { it.toDomain() }
suspend fun getArtistDetail(artistId: String): Pair<Artist, List<Album>> {
val body = subsonicApi.getArtist(artistId).subsonicResponse
val dto = body.artist ?: error(body.error?.message ?: "Artist not found")
val artist = Artist(id = dto.id, name = dto.name, albumCount = dto.albumCount, coverArtId = dto.coverArt)
return artist to dto.album.map { it.toDomain() }
}
suspend fun getAlbumList(type: String, size: Int = 20): List<Album> =
subsonicApi.getAlbumList2(type = type, size = size).subsonicResponse.albumList2?.album.orEmpty()
.map { it.toDomain() }
suspend fun getAlbumDetail(albumId: String): Pair<Album, List<Song>> {
val body = subsonicApi.getAlbum(albumId).subsonicResponse
val dto = body.album ?: error(body.error?.message ?: "Album not found")
return dto.toDomain() to dto.song.map { it.toDomain() }
}
suspend fun search(query: String): SearchResults {
val dto = subsonicApi.search3(query = query).subsonicResponse.searchResult3
return SearchResults(
artists = dto?.artist.orEmpty().map { it.toDomain() },
albums = dto?.album.orEmpty().map { it.toDomain() },
songs = dto?.song.orEmpty().map { it.toDomain() },
)
}
}
private fun ArtistSummaryDto.toDomain() = Artist(
id = id,
name = name,
albumCount = albumCount,
coverArtId = coverArt,
)
private fun AlbumSummaryDto.toDomain() = Album(
id = id,
name = name,
artistName = artist.orEmpty(),
artistId = artistId,
coverArtId = coverArt,
songCount = songCount,
year = year,
)
private fun AlbumDetailDto.toDomain() = Album(
id = id,
name = name,
artistName = artist.orEmpty(),
artistId = artistId,
coverArtId = coverArt,
songCount = songCount,
year = year,
)
private fun SongDto.toDomain() = Song(
id = id,
title = title,
albumName = album,
artistName = artist,
albumId = albumId,
artistId = artistId,
coverArtId = coverArt,
track = track,
durationSeconds = duration,
)
@@ -17,9 +17,11 @@ import javax.inject.Singleton
/**
* Retrofit's base URL is a non-functional placeholder: [SubsonicRequestInterceptor]
* rewrites every outgoing request onto the user's actual saved server URL.
* rewrites every outgoing request onto the user's actual saved server URL. Internal
* (not private) so [com.InfernalAquatics.deepwave.data.network.coverArtUrl] can build
* cover-art request URLs that resolve the same way through the same interceptor.
*/
private const val PLACEHOLDER_BASE_URL = "http://localhost/"
internal const val PLACEHOLDER_BASE_URL = "http://localhost/"
@Module
@InstallIn(SingletonComponent::class)
@@ -0,0 +1,17 @@
package com.InfernalAquatics.deepwave.di
import dagger.hilt.EntryPoint
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import okhttp3.OkHttpClient
/**
* Lets [com.InfernalAquatics.deepwave.DeepwaveApplication] pull the signed [OkHttpClient]
* out of the Hilt graph for Coil's [coil3.SingletonImageLoader.Factory], which Coil
* constructs itself rather than through Hilt injection.
*/
@EntryPoint
@InstallIn(SingletonComponent::class)
interface OkHttpClientEntryPoint {
fun okHttpClient(): OkHttpClient
}
@@ -21,6 +21,7 @@ fun AlbumCard(
title: String,
subtitle: String,
modifier: Modifier = Modifier,
coverArtUrl: String? = null,
onClick: () -> Unit = {},
) {
Column(
@@ -28,7 +29,7 @@ fun AlbumCard(
.width(AlbumCardWidth)
.clickable(onClick = onClick),
) {
ArtworkPlaceholder(size = AlbumCardWidth)
Artwork(imageUrl = coverArtUrl, size = AlbumCardWidth)
Text(
text = title,
style = MaterialTheme.typography.bodyMedium,
@@ -22,6 +22,7 @@ private val ArtistCardWidth = 110.dp
fun ArtistCard(
name: String,
modifier: Modifier = Modifier,
coverArtUrl: String? = null,
onClick: () -> Unit = {},
) {
Column(
@@ -30,7 +31,7 @@ fun ArtistCard(
.clickable(onClick = onClick),
horizontalAlignment = Alignment.CenterHorizontally,
) {
ArtworkPlaceholder(size = ArtistCardWidth, circular = true)
Artwork(imageUrl = coverArtUrl, size = ArtistCardWidth, circular = true)
Text(
text = name,
style = MaterialTheme.typography.bodyMedium,
@@ -0,0 +1,51 @@
package com.InfernalAquatics.deepwave.ui.components
import android.content.res.Configuration
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage
import com.InfernalAquatics.deepwave.ui.theme.DeepwaveTheme
/** Cover art for a track/album/artist: real image via Coil when a URL is known, else the placeholder icon. */
@Composable
fun Artwork(
imageUrl: String?,
size: Dp,
modifier: Modifier = Modifier,
circular: Boolean = false,
) {
if (imageUrl == null) {
ArtworkPlaceholder(size = size, modifier = modifier, circular = circular)
} else {
AsyncImage(
model = imageUrl,
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = modifier
.size(size)
.clip(if (circular) CircleShape else RoundedCornerShape(8.dp))
.background(MaterialTheme.colorScheme.surfaceVariant),
)
}
}
@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun ArtworkPreview() {
DeepwaveTheme {
Row {
Artwork(imageUrl = null, size = 96.dp)
}
}
}
@@ -22,6 +22,8 @@ fun TrackRow(
title: String,
subtitle: String,
modifier: Modifier = Modifier,
coverArtUrl: String? = null,
circular: Boolean = false,
trailingContent: (@Composable RowScope.() -> Unit)? = null,
onClick: () -> Unit = {},
) {
@@ -32,7 +34,7 @@ fun TrackRow(
.padding(horizontal = 16.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
ArtworkPlaceholder(size = 48.dp)
Artwork(imageUrl = coverArtUrl, size = 48.dp, circular = circular)
Column(
modifier = Modifier
.weight(1f)
@@ -1,68 +0,0 @@
package com.InfernalAquatics.deepwave.ui.home
import androidx.compose.foundation.layout.Arrangement
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.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import android.content.res.Configuration
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.InfernalAquatics.deepwave.R
import com.InfernalAquatics.deepwave.ui.components.AlbumCard
import com.InfernalAquatics.deepwave.ui.theme.DeepwaveTheme
/** Sample content demonstrating the design system; replaced by real library data in Phase 3. */
@Composable
fun HomeScreen(modifier: Modifier = Modifier) {
Column(
modifier = modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(vertical = 16.dp),
verticalArrangement = Arrangement.spacedBy(24.dp),
) {
HomeRow(title = stringResource(R.string.home_row_recently_added))
HomeRow(title = stringResource(R.string.home_row_recently_played))
HomeRow(title = stringResource(R.string.home_row_made_for_you))
}
}
@Composable
private fun HomeRow(title: String) {
Column {
Text(
text = title,
style = MaterialTheme.typography.titleLarge,
modifier = Modifier.padding(horizontal = 16.dp),
)
Spacer(modifier = Modifier.height(12.dp))
LazyRow(
contentPadding = PaddingValues(horizontal = 16.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp),
) {
items(5) { index ->
AlbumCard(title = "Sample Album ${index + 1}", subtitle = "Sample Artist")
}
}
}
}
@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES, widthDp = 411, heightDp = 891)
@Composable
private fun HomeScreenPreview() {
DeepwaveTheme {
HomeScreen()
}
}
@@ -0,0 +1,141 @@
package com.InfernalAquatics.deepwave.ui.library
import android.content.res.Configuration
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
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.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
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.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import com.InfernalAquatics.deepwave.R
import com.InfernalAquatics.deepwave.data.model.Album
import com.InfernalAquatics.deepwave.data.model.Song
import com.InfernalAquatics.deepwave.data.network.coverArtUrl
import com.InfernalAquatics.deepwave.ui.components.Artwork
import com.InfernalAquatics.deepwave.ui.components.TrackRow
import com.InfernalAquatics.deepwave.ui.theme.DeepwaveTheme
@Composable
fun AlbumDetailScreen(
onBack: () -> Unit,
viewModel: AlbumDetailViewModel = hiltViewModel(),
) {
val uiState by viewModel.uiState.collectAsState()
AlbumDetailContent(uiState = uiState, onBack = onBack)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun AlbumDetailContent(uiState: AlbumDetailUiState, onBack: () -> Unit) {
Scaffold(
topBar = {
TopAppBar(
title = {},
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.action_back))
}
},
)
},
) { innerPadding ->
Box(modifier = Modifier.fillMaxSize().padding(innerPadding)) {
when {
uiState.isLoading -> Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
uiState.errorMessage != null -> Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text(text = uiState.errorMessage, style = MaterialTheme.typography.bodyLarge)
}
else -> LazyColumn(modifier = Modifier.fillMaxSize()) {
item { AlbumHeader(uiState.album) }
items(uiState.songs, key = { it.id }) { song ->
TrackRow(
title = song.title,
subtitle = song.artistName ?: uiState.album?.artistName.orEmpty(),
coverArtUrl = coverArtUrl(song.coverArtId ?: uiState.album?.coverArtId),
)
}
}
}
}
}
}
@Composable
private fun AlbumHeader(album: Album?) {
if (album == null) return
Column(
modifier = Modifier
.fillMaxWidth()
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Artwork(imageUrl = coverArtUrl(album.coverArtId), size = 180.dp)
Spacer(modifier = Modifier.height(16.dp))
Text(text = album.name, style = MaterialTheme.typography.headlineSmall, textAlign = TextAlign.Center)
Text(
text = album.year?.let { "${album.artistName} · $it" } ?: album.artistName,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
}
}
private val sampleAlbum = Album(
id = "sample",
name = "Sample Album",
artistName = "Sample Artist",
artistId = "artist-sample",
coverArtId = null,
songCount = 4,
year = 2024,
)
private val sampleSongs = List(4) { index ->
Song(
id = "sample-song-$index",
title = "Sample Track ${index + 1}",
albumName = sampleAlbum.name,
artistName = sampleAlbum.artistName,
albumId = sampleAlbum.id,
artistId = sampleAlbum.artistId,
coverArtId = null,
track = index + 1,
durationSeconds = 200,
)
}
@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES, widthDp = 411, heightDp = 891)
@Composable
private fun AlbumDetailContentPreview() {
DeepwaveTheme {
AlbumDetailContent(
uiState = AlbumDetailUiState(isLoading = false, album = sampleAlbum, songs = sampleSongs),
onBack = {},
)
}
}
@@ -0,0 +1,57 @@
package com.InfernalAquatics.deepwave.ui.library
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.navigation.toRoute
import com.InfernalAquatics.deepwave.data.model.Album
import com.InfernalAquatics.deepwave.data.model.Song
import com.InfernalAquatics.deepwave.data.repository.LibraryRepository
import com.InfernalAquatics.deepwave.ui.navigation.Route
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import retrofit2.HttpException
import java.io.IOException
import javax.inject.Inject
data class AlbumDetailUiState(
val isLoading: Boolean = true,
val album: Album? = null,
val songs: List<Song> = emptyList(),
val errorMessage: String? = null,
)
@HiltViewModel
class AlbumDetailViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
private val libraryRepository: LibraryRepository,
) : ViewModel() {
private val albumId = savedStateHandle.toRoute<Route.AlbumDetail>().albumId
private val _uiState = MutableStateFlow(AlbumDetailUiState())
val uiState: StateFlow<AlbumDetailUiState> = _uiState.asStateFlow()
init {
load()
}
private fun load() {
viewModelScope.launch {
_uiState.update { it.copy(isLoading = true, errorMessage = null) }
try {
val (album, songs) = libraryRepository.getAlbumDetail(albumId)
_uiState.update { it.copy(isLoading = false, album = album, songs = songs) }
} catch (e: IOException) {
_uiState.update { it.copy(isLoading = false, errorMessage = "Couldn't reach server") }
} catch (e: HttpException) {
_uiState.update { it.copy(isLoading = false, errorMessage = "Server error: ${e.message()}") }
} catch (e: IllegalStateException) {
_uiState.update { it.copy(isLoading = false, errorMessage = e.message) }
}
}
}
}
@@ -0,0 +1,109 @@
package com.InfernalAquatics.deepwave.ui.library
import android.content.res.Configuration
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
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.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import com.InfernalAquatics.deepwave.R
import com.InfernalAquatics.deepwave.data.model.Album
import com.InfernalAquatics.deepwave.data.model.Artist
import com.InfernalAquatics.deepwave.data.network.coverArtUrl
import com.InfernalAquatics.deepwave.ui.components.TrackRow
import com.InfernalAquatics.deepwave.ui.theme.DeepwaveTheme
@Composable
fun ArtistDetailScreen(
onBack: () -> Unit,
onAlbumClick: (String) -> Unit,
viewModel: ArtistDetailViewModel = hiltViewModel(),
) {
val uiState by viewModel.uiState.collectAsState()
ArtistDetailContent(uiState = uiState, onBack = onBack, onAlbumClick = onAlbumClick)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun ArtistDetailContent(
uiState: ArtistDetailUiState,
onBack: () -> Unit,
onAlbumClick: (String) -> Unit,
) {
Scaffold(
topBar = {
TopAppBar(
title = { Text(uiState.artist?.name.orEmpty()) },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.action_back))
}
},
)
},
) { innerPadding ->
Box(modifier = Modifier.fillMaxSize().padding(innerPadding)) {
when {
uiState.isLoading -> Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
uiState.errorMessage != null -> Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text(text = uiState.errorMessage, style = MaterialTheme.typography.bodyLarge)
}
else -> LazyColumn(modifier = Modifier.fillMaxSize()) {
items(uiState.albums, key = { it.id }) { album ->
TrackRow(
title = album.name,
subtitle = album.year?.toString().orEmpty(),
coverArtUrl = coverArtUrl(album.coverArtId),
onClick = { onAlbumClick(album.id) },
)
}
}
}
}
}
}
private val sampleArtist = Artist(id = "sample", name = "Sample Artist", albumCount = 3, coverArtId = null)
private val sampleAlbums = List(3) { index ->
Album(
id = "sample-$index",
name = "Sample Album ${index + 1}",
artistName = sampleArtist.name,
artistId = sampleArtist.id,
coverArtId = null,
songCount = 10,
year = 2020 + index,
)
}
@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES, widthDp = 411, heightDp = 891)
@Composable
private fun ArtistDetailContentPreview() {
DeepwaveTheme {
ArtistDetailContent(
uiState = ArtistDetailUiState(isLoading = false, artist = sampleArtist, albums = sampleAlbums),
onBack = {},
onAlbumClick = {},
)
}
}
@@ -0,0 +1,57 @@
package com.InfernalAquatics.deepwave.ui.library
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.navigation.toRoute
import com.InfernalAquatics.deepwave.data.model.Album
import com.InfernalAquatics.deepwave.data.model.Artist
import com.InfernalAquatics.deepwave.data.repository.LibraryRepository
import com.InfernalAquatics.deepwave.ui.navigation.Route
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import retrofit2.HttpException
import java.io.IOException
import javax.inject.Inject
data class ArtistDetailUiState(
val isLoading: Boolean = true,
val artist: Artist? = null,
val albums: List<Album> = emptyList(),
val errorMessage: String? = null,
)
@HiltViewModel
class ArtistDetailViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
private val libraryRepository: LibraryRepository,
) : ViewModel() {
private val artistId = savedStateHandle.toRoute<Route.ArtistDetail>().artistId
private val _uiState = MutableStateFlow(ArtistDetailUiState())
val uiState: StateFlow<ArtistDetailUiState> = _uiState.asStateFlow()
init {
load()
}
private fun load() {
viewModelScope.launch {
_uiState.update { it.copy(isLoading = true, errorMessage = null) }
try {
val (artist, albums) = libraryRepository.getArtistDetail(artistId)
_uiState.update { it.copy(isLoading = false, artist = artist, albums = albums) }
} catch (e: IOException) {
_uiState.update { it.copy(isLoading = false, errorMessage = "Couldn't reach server") }
} catch (e: HttpException) {
_uiState.update { it.copy(isLoading = false, errorMessage = "Server error: ${e.message()}") }
} catch (e: IllegalStateException) {
_uiState.update { it.copy(isLoading = false, errorMessage = e.message) }
}
}
}
}
@@ -0,0 +1,72 @@
package com.InfernalAquatics.deepwave.ui.library
import android.content.res.Configuration
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import com.InfernalAquatics.deepwave.data.model.Artist
import com.InfernalAquatics.deepwave.data.network.coverArtUrl
import com.InfernalAquatics.deepwave.ui.components.TrackRow
import com.InfernalAquatics.deepwave.ui.theme.DeepwaveTheme
@Composable
fun ArtistsScreen(
onArtistClick: (String) -> Unit,
modifier: Modifier = Modifier,
viewModel: ArtistsViewModel = hiltViewModel(),
) {
val uiState by viewModel.uiState.collectAsState()
ArtistsContent(uiState = uiState, onArtistClick = onArtistClick, modifier = modifier)
}
@Composable
private fun ArtistsContent(
uiState: ArtistsUiState,
onArtistClick: (String) -> Unit,
modifier: Modifier = Modifier,
) {
when {
uiState.isLoading -> Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
uiState.errorMessage != null -> Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text(text = uiState.errorMessage, style = MaterialTheme.typography.bodyLarge)
}
else -> LazyColumn(modifier = modifier.fillMaxSize()) {
items(uiState.artists, key = { it.id }) { artist ->
TrackRow(
title = artist.name,
subtitle = albumCountLabel(artist.albumCount),
coverArtUrl = coverArtUrl(artist.coverArtId),
circular = true,
onClick = { onArtistClick(artist.id) },
)
}
}
}
}
private fun albumCountLabel(count: Int): String = if (count == 1) "1 album" else "$count albums"
private val sampleArtists = List(8) { index ->
Artist(id = "sample-$index", name = "Sample Artist ${index + 1}", albumCount = index + 1, coverArtId = null)
}
@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES, widthDp = 411, heightDp = 891)
@Composable
private fun ArtistsContentPreview() {
DeepwaveTheme {
ArtistsContent(uiState = ArtistsUiState(isLoading = false, artists = sampleArtists), onArtistClick = {})
}
}
@@ -0,0 +1,47 @@
package com.InfernalAquatics.deepwave.ui.library
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.InfernalAquatics.deepwave.data.model.Artist
import com.InfernalAquatics.deepwave.data.repository.LibraryRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import retrofit2.HttpException
import java.io.IOException
import javax.inject.Inject
data class ArtistsUiState(
val isLoading: Boolean = true,
val artists: List<Artist> = emptyList(),
val errorMessage: String? = null,
)
@HiltViewModel
class ArtistsViewModel @Inject constructor(
private val libraryRepository: LibraryRepository,
) : ViewModel() {
private val _uiState = MutableStateFlow(ArtistsUiState())
val uiState: StateFlow<ArtistsUiState> = _uiState.asStateFlow()
init {
load()
}
fun load() {
viewModelScope.launch {
_uiState.update { it.copy(isLoading = true, errorMessage = null) }
try {
val artists = libraryRepository.getArtists()
_uiState.update { it.copy(isLoading = false, artists = artists) }
} catch (e: IOException) {
_uiState.update { it.copy(isLoading = false, errorMessage = "Couldn't reach server") }
} catch (e: HttpException) {
_uiState.update { it.copy(isLoading = false, errorMessage = "Server error: ${e.message()}") }
}
}
}
}
@@ -0,0 +1,131 @@
package com.InfernalAquatics.deepwave.ui.library
import android.content.res.Configuration
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.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import com.InfernalAquatics.deepwave.R
import com.InfernalAquatics.deepwave.data.model.Album
import com.InfernalAquatics.deepwave.data.network.coverArtUrl
import com.InfernalAquatics.deepwave.ui.components.AlbumCard
import com.InfernalAquatics.deepwave.ui.theme.DeepwaveTheme
@Composable
fun HomeScreen(
onAlbumClick: (String) -> Unit,
modifier: Modifier = Modifier,
viewModel: HomeViewModel = hiltViewModel(),
) {
val uiState by viewModel.uiState.collectAsState()
HomeContent(uiState = uiState, onAlbumClick = onAlbumClick, modifier = modifier)
}
@Composable
private fun HomeContent(
uiState: HomeUiState,
onAlbumClick: (String) -> Unit,
modifier: Modifier = Modifier,
) {
when {
uiState.isLoading -> Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
uiState.errorMessage != null -> Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text(text = uiState.errorMessage, style = MaterialTheme.typography.bodyLarge)
}
else -> Column(
modifier = modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(vertical = 16.dp),
verticalArrangement = Arrangement.spacedBy(24.dp),
) {
HomeRow(title = stringResource(R.string.home_row_recently_added), albums = uiState.recentlyAdded, onAlbumClick = onAlbumClick)
HomeRow(title = stringResource(R.string.home_row_recently_played), albums = uiState.recentlyPlayed, onAlbumClick = onAlbumClick)
HomeRow(title = stringResource(R.string.home_row_made_for_you), albums = uiState.madeForYou, onAlbumClick = onAlbumClick)
}
}
}
@Composable
private fun HomeRow(title: String, albums: List<Album>, onAlbumClick: (String) -> Unit) {
if (albums.isEmpty()) return
Column {
Text(
text = title,
style = MaterialTheme.typography.titleLarge,
modifier = Modifier.padding(horizontal = 16.dp),
)
Spacer(modifier = Modifier.height(12.dp))
LazyRow(
contentPadding = PaddingValues(horizontal = 16.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp),
) {
items(albums, key = { it.id }) { album ->
AlbumCard(
title = album.name,
subtitle = album.artistName,
coverArtUrl = coverArtUrl(album.coverArtId),
onClick = { onAlbumClick(album.id) },
)
}
}
}
}
private val sampleAlbums = List(5) { index ->
Album(
id = "sample-$index",
name = "Sample Album ${index + 1}",
artistName = "Sample Artist",
artistId = null,
coverArtId = null,
songCount = 10,
year = 2024,
)
}
@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES, widthDp = 411, heightDp = 891)
@Composable
private fun HomeContentPreview() {
DeepwaveTheme {
HomeContent(
uiState = HomeUiState(
isLoading = false,
recentlyAdded = sampleAlbums,
recentlyPlayed = sampleAlbums,
madeForYou = sampleAlbums,
),
onAlbumClick = {},
)
}
}
@Preview(name = "Loading", showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES, widthDp = 411, heightDp = 891)
@Composable
private fun HomeContentLoadingPreview() {
DeepwaveTheme {
HomeContent(uiState = HomeUiState(isLoading = true), onAlbumClick = {})
}
}
@@ -0,0 +1,59 @@
package com.InfernalAquatics.deepwave.ui.library
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.InfernalAquatics.deepwave.data.model.Album
import com.InfernalAquatics.deepwave.data.repository.AlbumListType
import com.InfernalAquatics.deepwave.data.repository.LibraryRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import retrofit2.HttpException
import java.io.IOException
import javax.inject.Inject
data class HomeUiState(
val isLoading: Boolean = true,
val recentlyAdded: List<Album> = emptyList(),
val recentlyPlayed: List<Album> = emptyList(),
val madeForYou: List<Album> = emptyList(),
val errorMessage: String? = null,
)
@HiltViewModel
class HomeViewModel @Inject constructor(
private val libraryRepository: LibraryRepository,
) : ViewModel() {
private val _uiState = MutableStateFlow(HomeUiState())
val uiState: StateFlow<HomeUiState> = _uiState.asStateFlow()
init {
load()
}
fun load() {
viewModelScope.launch {
_uiState.update { it.copy(isLoading = true, errorMessage = null) }
try {
val recentlyAdded = libraryRepository.getAlbumList(AlbumListType.NEWEST)
val recentlyPlayed = libraryRepository.getAlbumList(AlbumListType.RECENT)
val madeForYou = libraryRepository.getAlbumList(AlbumListType.RANDOM)
_uiState.update {
it.copy(
isLoading = false,
recentlyAdded = recentlyAdded,
recentlyPlayed = recentlyPlayed,
madeForYou = madeForYou,
)
}
} catch (e: IOException) {
_uiState.update { it.copy(isLoading = false, errorMessage = "Couldn't reach server") }
} catch (e: HttpException) {
_uiState.update { it.copy(isLoading = false, errorMessage = "Server error: ${e.message()}") }
}
}
}
}
@@ -1,30 +0,0 @@
package com.InfernalAquatics.deepwave.ui.library
import android.content.res.Configuration
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import com.InfernalAquatics.deepwave.R
import com.InfernalAquatics.deepwave.ui.theme.DeepwaveTheme
/** Placeholder; real library browsing (artists/albums) arrives in Phase 3. */
@Composable
fun LibraryScreen(modifier: Modifier = Modifier) {
Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text(text = stringResource(R.string.library_placeholder), style = MaterialTheme.typography.bodyLarge)
}
}
@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES, widthDp = 411, heightDp = 891)
@Composable
private fun LibraryScreenPreview() {
DeepwaveTheme {
LibraryScreen()
}
}
@@ -0,0 +1,175 @@
package com.InfernalAquatics.deepwave.ui.library
import android.content.res.Configuration
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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import com.InfernalAquatics.deepwave.R
import com.InfernalAquatics.deepwave.data.model.Album
import com.InfernalAquatics.deepwave.data.model.Artist
import com.InfernalAquatics.deepwave.data.model.Song
import com.InfernalAquatics.deepwave.data.network.coverArtUrl
import com.InfernalAquatics.deepwave.data.repository.SearchResults
import com.InfernalAquatics.deepwave.ui.components.AlbumCard
import com.InfernalAquatics.deepwave.ui.components.ArtistCard
import com.InfernalAquatics.deepwave.ui.components.TrackRow
import com.InfernalAquatics.deepwave.ui.theme.DeepwaveTheme
@Composable
fun SearchScreen(
onArtistClick: (String) -> Unit,
onAlbumClick: (String) -> Unit,
modifier: Modifier = Modifier,
viewModel: SearchViewModel = hiltViewModel(),
) {
val uiState by viewModel.uiState.collectAsState()
SearchContent(
uiState = uiState,
onQueryChange = viewModel::onQueryChange,
onArtistClick = onArtistClick,
onAlbumClick = onAlbumClick,
modifier = modifier,
)
}
@Composable
private fun SearchContent(
uiState: SearchUiState,
onQueryChange: (String) -> Unit,
onArtistClick: (String) -> Unit,
onAlbumClick: (String) -> Unit,
modifier: Modifier = Modifier,
) {
Column(modifier = modifier.fillMaxSize()) {
OutlinedTextField(
value = uiState.query,
onValueChange = onQueryChange,
placeholder = { Text(stringResource(R.string.search_hint)) },
leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null) },
singleLine = true,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
)
val results = uiState.results
when {
uiState.isLoading -> Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
uiState.errorMessage != null -> Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text(text = uiState.errorMessage, style = MaterialTheme.typography.bodyLarge)
}
results != null && results.artists.isEmpty() && results.albums.isEmpty() && results.songs.isEmpty() ->
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text(text = stringResource(R.string.search_no_results), style = MaterialTheme.typography.bodyLarge)
}
results != null -> SearchResultsList(results = results, onArtistClick = onArtistClick, onAlbumClick = onAlbumClick)
}
}
}
@Composable
private fun SearchResultsList(
results: SearchResults,
onArtistClick: (String) -> Unit,
onAlbumClick: (String) -> Unit,
) {
LazyColumn(modifier = Modifier.fillMaxSize()) {
if (results.artists.isNotEmpty()) {
item { SectionHeader(stringResource(R.string.search_section_artists)) }
item {
LazyRow(
contentPadding = PaddingValues(horizontal = 16.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp),
) {
items(results.artists, key = { it.id }) { artist ->
ArtistCard(
name = artist.name,
coverArtUrl = coverArtUrl(artist.coverArtId),
onClick = { onArtistClick(artist.id) },
)
}
}
}
}
if (results.albums.isNotEmpty()) {
item { SectionHeader(stringResource(R.string.search_section_albums)) }
item {
LazyRow(
contentPadding = PaddingValues(horizontal = 16.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp),
) {
items(results.albums, key = { it.id }) { album ->
AlbumCard(
title = album.name,
subtitle = album.artistName,
coverArtUrl = coverArtUrl(album.coverArtId),
onClick = { onAlbumClick(album.id) },
)
}
}
}
}
if (results.songs.isNotEmpty()) {
item { SectionHeader(stringResource(R.string.search_section_songs)) }
items(results.songs, key = { it.id }) { song ->
TrackRow(
title = song.title,
subtitle = song.artistName.orEmpty(),
coverArtUrl = coverArtUrl(song.coverArtId),
)
}
}
}
}
@Composable
private fun SectionHeader(title: String) {
Text(
text = title,
style = MaterialTheme.typography.titleLarge,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
)
}
private val sampleResults = SearchResults(
artists = List(3) { Artist(id = "artist-$it", name = "Sample Artist ${it + 1}", albumCount = 2, coverArtId = null) },
albums = List(3) { Album(id = "album-$it", name = "Sample Album ${it + 1}", artistName = "Sample Artist", artistId = null, coverArtId = null, songCount = 8, year = 2023) },
songs = List(3) { Song(id = "song-$it", title = "Sample Track ${it + 1}", albumName = "Sample Album", artistName = "Sample Artist", albumId = null, artistId = null, coverArtId = null, track = it + 1, durationSeconds = 200) },
)
@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES, widthDp = 411, heightDp = 891)
@Composable
private fun SearchContentPreview() {
DeepwaveTheme {
SearchContent(
uiState = SearchUiState(query = "sample", results = sampleResults),
onQueryChange = {},
onArtistClick = {},
onAlbumClick = {},
)
}
}
@@ -0,0 +1,57 @@
package com.InfernalAquatics.deepwave.ui.library
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.InfernalAquatics.deepwave.data.repository.LibraryRepository
import com.InfernalAquatics.deepwave.data.repository.SearchResults
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import retrofit2.HttpException
import java.io.IOException
import javax.inject.Inject
private const val SEARCH_DEBOUNCE_MS = 400L
data class SearchUiState(
val query: String = "",
val isLoading: Boolean = false,
val results: SearchResults? = null,
val errorMessage: String? = null,
)
@HiltViewModel
class SearchViewModel @Inject constructor(
private val libraryRepository: LibraryRepository,
) : ViewModel() {
private val _uiState = MutableStateFlow(SearchUiState())
val uiState: StateFlow<SearchUiState> = _uiState.asStateFlow()
private var searchJob: Job? = null
fun onQueryChange(query: String) {
_uiState.update { it.copy(query = query, errorMessage = null) }
searchJob?.cancel()
if (query.isBlank()) {
_uiState.update { it.copy(isLoading = false, results = null) }
return
}
searchJob = viewModelScope.launch {
delay(SEARCH_DEBOUNCE_MS)
_uiState.update { it.copy(isLoading = true) }
try {
val results = libraryRepository.search(query)
_uiState.update { it.copy(isLoading = false, results = results) }
} catch (e: IOException) {
_uiState.update { it.copy(isLoading = false, errorMessage = "Couldn't reach server") }
} catch (e: HttpException) {
_uiState.update { it.copy(isLoading = false, errorMessage = "Server error: ${e.message()}") }
}
}
}
}
@@ -13,6 +13,8 @@ import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import com.InfernalAquatics.deepwave.ui.library.AlbumDetailScreen
import com.InfernalAquatics.deepwave.ui.library.ArtistDetailScreen
import com.InfernalAquatics.deepwave.ui.login.LoginScreen
import com.InfernalAquatics.deepwave.ui.settings.SettingsScreen
@@ -41,7 +43,11 @@ fun DeepwaveNavHost(
)
}
composable<Route.Main> {
MainScreen(onOpenSettings = { navController.navigate(Route.Settings) })
MainScreen(
onOpenSettings = { navController.navigate(Route.Settings) },
onOpenArtist = { artistId -> navController.navigate(Route.ArtistDetail(artistId)) },
onOpenAlbum = { albumId -> navController.navigate(Route.AlbumDetail(albumId)) },
)
}
composable<Route.Settings> {
SettingsScreen(
@@ -53,6 +59,15 @@ fun DeepwaveNavHost(
},
)
}
composable<Route.ArtistDetail> {
ArtistDetailScreen(
onBack = { navController.popBackStack() },
onAlbumClick = { albumId -> navController.navigate(Route.AlbumDetail(albumId)) },
)
}
composable<Route.AlbumDetail> {
AlbumDetailScreen(onBack = { navController.popBackStack() })
}
}
}
}
@@ -25,15 +25,19 @@ import com.InfernalAquatics.deepwave.R
import com.InfernalAquatics.deepwave.ui.components.BottomNavBar
import com.InfernalAquatics.deepwave.ui.components.BottomTab
import com.InfernalAquatics.deepwave.ui.components.MiniPlayerBar
import com.InfernalAquatics.deepwave.ui.home.HomeScreen
import com.InfernalAquatics.deepwave.ui.library.LibraryScreen
import com.InfernalAquatics.deepwave.ui.search.SearchScreen
import com.InfernalAquatics.deepwave.ui.library.ArtistsScreen
import com.InfernalAquatics.deepwave.ui.library.HomeScreen
import com.InfernalAquatics.deepwave.ui.library.SearchScreen
import com.InfernalAquatics.deepwave.ui.theme.DeepwaveTheme
/** Hosts the bottom-tab shell (Home / Search / Library) plus the mini-player bar and settings entry point. */
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MainScreen(onOpenSettings: () -> Unit) {
fun MainScreen(
onOpenSettings: () -> Unit,
onOpenArtist: (String) -> Unit,
onOpenAlbum: (String) -> Unit,
) {
var selectedTab by remember { mutableStateOf(BottomTab.Home) }
Scaffold(
@@ -60,9 +64,9 @@ fun MainScreen(onOpenSettings: () -> Unit) {
.padding(innerPadding),
) {
when (selectedTab) {
BottomTab.Home -> HomeScreen()
BottomTab.Search -> SearchScreen()
BottomTab.Library -> LibraryScreen()
BottomTab.Home -> HomeScreen(onAlbumClick = onOpenAlbum)
BottomTab.Search -> SearchScreen(onArtistClick = onOpenArtist, onAlbumClick = onOpenAlbum)
BottomTab.Library -> ArtistsScreen(onArtistClick = onOpenArtist)
}
}
}
@@ -73,6 +77,6 @@ fun MainScreen(onOpenSettings: () -> Unit) {
@Composable
private fun MainScreenPreview() {
DeepwaveTheme {
MainScreen(onOpenSettings = {})
MainScreen(onOpenSettings = {}, onOpenArtist = {}, onOpenAlbum = {})
}
}
@@ -6,4 +6,6 @@ sealed interface Route {
@Serializable data object Login : Route
@Serializable data object Main : Route
@Serializable data object Settings : Route
@Serializable data class ArtistDetail(val artistId: String) : Route
@Serializable data class AlbumDetail(val albumId: String) : Route
}
@@ -1,30 +0,0 @@
package com.InfernalAquatics.deepwave.ui.search
import android.content.res.Configuration
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import com.InfernalAquatics.deepwave.R
import com.InfernalAquatics.deepwave.ui.theme.DeepwaveTheme
/** Placeholder; real search UI arrives in Phase 3. */
@Composable
fun SearchScreen(modifier: Modifier = Modifier) {
Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text(text = stringResource(R.string.search_placeholder), style = MaterialTheme.typography.bodyLarge)
}
}
@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES, widthDp = 411, heightDp = 891)
@Composable
private fun SearchScreenPreview() {
DeepwaveTheme {
SearchScreen()
}
}
+6 -3
View File
@@ -18,13 +18,16 @@
<string name="settings_title">Settings</string>
<string name="settings_log_out">Log out</string>
<string name="search_placeholder">Search coming soon</string>
<string name="library_placeholder">Your library coming soon</string>
<string name="player_nothing_playing">Nothing playing</string>
<string name="player_play">Play</string>
<string name="home_row_recently_added">Recently Added</string>
<string name="home_row_recently_played">Recently Played</string>
<string name="home_row_made_for_you">Made For You</string>
<string name="search_hint">Artists, albums, songs</string>
<string name="search_no_results">No results</string>
<string name="search_section_artists">Artists</string>
<string name="search_section_albums">Albums</string>
<string name="search_section_songs">Songs</string>
</resources>
@@ -0,0 +1,78 @@
package com.InfernalAquatics.deepwave.data.network
import com.InfernalAquatics.deepwave.data.security.CredentialsStore
import com.InfernalAquatics.deepwave.data.security.ServerCredentials
import kotlinx.coroutines.runBlocking
import mockwebserver3.MockResponse
import mockwebserver3.MockWebServer
import okhttp3.OkHttpClient
import okhttp3.Request
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Before
import org.junit.Test
import org.mockito.Mockito
/**
* Covers the URL-rewriting and auth-signing logic every Subsonic request goes through.
* [CredentialsStore] is mocked rather than constructed for real: its `credentials` property
* eagerly touches Preferences DataStore in the constructor body (not lazily), which needs a
* working Android Context unavailable in a plain JVM unit test. Mockito's class mock never
* runs the real constructor, so this sidesteps that entirely - only [CredentialsStore.currentForRequest]
* (the one method the interceptor actually calls) needs stubbing.
*/
class SubsonicRequestInterceptorTest {
private lateinit var server: MockWebServer
private lateinit var client: OkHttpClient
@Before
fun setUp() {
server = MockWebServer()
server.start()
val credentialsStore = Mockito.mock(CredentialsStore::class.java)
val credentials = ServerCredentials(
serverUrl = server.url("/music/").toString(),
username = "alex",
password = "hunter2",
)
runBlocking {
Mockito.`when`(credentialsStore.currentForRequest()).thenReturn(credentials)
}
client = OkHttpClient.Builder()
.addInterceptor(SubsonicRequestInterceptor(credentialsStore))
.build()
}
@After
fun tearDown() {
server.close()
}
@Test
fun `rewrites the placeholder host onto the saved server URL, preserving its path prefix`() {
server.enqueue(MockResponse.Builder().body("{}").build())
client.newCall(Request.Builder().url("http://localhost/rest/ping").build()).execute().use { }
val recorded = server.takeRequest()
assertEquals("/music/rest/ping", recorded.url.encodedPath)
}
@Test
fun `signs every request with the Subsonic token-auth query params`() {
server.enqueue(MockResponse.Builder().body("{}").build())
client.newCall(Request.Builder().url("http://localhost/rest/ping").build()).execute().use { }
val recorded = server.takeRequest()
assertEquals("alex", recorded.url.queryParameter("u"))
assertEquals("Deepwave", recorded.url.queryParameter("c"))
assertEquals("json", recorded.url.queryParameter("f"))
assertNotNull(recorded.url.queryParameter("t"))
assertNotNull(recorded.url.queryParameter("s"))
}
}
@@ -0,0 +1,145 @@
package com.InfernalAquatics.deepwave.data.repository
import com.InfernalAquatics.deepwave.data.network.SubsonicApi
import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory
import kotlinx.coroutines.test.runTest
import kotlinx.serialization.json.Json
import mockwebserver3.MockResponse
import mockwebserver3.MockWebServer
import okhttp3.MediaType.Companion.toMediaType
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Test
import retrofit2.Retrofit
/**
* Covers LibraryRepository's Subsonic-DTO-to-domain-model mapping against a real (mocked)
* HTTP server, bypassing Hilt/DI and [com.InfernalAquatics.deepwave.data.network.SubsonicRequestInterceptor]
* entirely - just Retrofit talking to [MockWebServer] like it would to a real Navidrome instance.
*/
class LibraryRepositoryTest {
private lateinit var server: MockWebServer
private lateinit var repository: LibraryRepository
@Before
fun setUp() {
server = MockWebServer()
server.start()
val json = Json { ignoreUnknownKeys = true; isLenient = true }
val retrofit = Retrofit.Builder()
.baseUrl(server.url("/"))
.addConverterFactory(json.asConverterFactory("application/json".toMediaType()))
.build()
repository = LibraryRepository(retrofit.create(SubsonicApi::class.java))
}
@After
fun tearDown() {
server.close()
}
@Test
fun `getArtists flattens the indexed artist list`() = runTest {
server.enqueue(
MockResponse.Builder()
.body(
"""
{"subsonic-response":{"status":"ok","version":"1.16.1","artists":{"index":[
{"name":"A","artist":[{"id":"1","name":"AC Slater","albumCount":1,"coverArt":"ar-1"}]},
{"name":"C","artist":[{"id":"2","name":"Calvin Harris","albumCount":2}]}
]}}}
""".trimIndent(),
)
.build(),
)
val artists = repository.getArtists()
assertEquals(2, artists.size)
assertEquals("AC Slater", artists[0].name)
assertEquals("ar-1", artists[0].coverArtId)
assertEquals(2, artists[1].albumCount)
}
@Test
fun `getAlbumList maps album summaries`() = runTest {
server.enqueue(
MockResponse.Builder()
.body(
"""
{"subsonic-response":{"status":"ok","version":"1.16.1","albumList2":{"album":[
{"id":"10","name":"Funk Wav Bounces","artist":"Calvin Harris","artistId":"2","coverArt":"al-10","songCount":12,"duration":2400,"year":2017}
]}}}
""".trimIndent(),
)
.build(),
)
val albums = repository.getAlbumList(type = AlbumListType.NEWEST)
assertEquals(1, albums.size)
assertEquals("Funk Wav Bounces", albums[0].name)
assertEquals("Calvin Harris", albums[0].artistName)
assertEquals(2017, albums[0].year)
}
@Test
fun `getAlbumDetail returns the album and its songs`() = runTest {
server.enqueue(
MockResponse.Builder()
.body(
"""
{"subsonic-response":{"status":"ok","version":"1.16.1","album":{
"id":"10","name":"Funk Wav Bounces","artist":"Calvin Harris","artistId":"2",
"songCount":1,"duration":200,"year":2017,
"song":[{"id":"100","title":"Slide","album":"Funk Wav Bounces","artist":"Calvin Harris","albumId":"10","artistId":"2","track":1,"duration":200}]
}}}
""".trimIndent(),
)
.build(),
)
val (album, songs) = repository.getAlbumDetail("10")
assertEquals("Funk Wav Bounces", album.name)
assertEquals(1, songs.size)
assertEquals("Slide", songs[0].title)
}
@Test(expected = IllegalStateException::class)
fun `getAlbumDetail throws with the server's error message when the album is missing`() = runTest {
server.enqueue(
MockResponse.Builder()
.body("""{"subsonic-response":{"status":"failed","version":"1.16.1","error":{"code":70,"message":"Album not found"}}}""")
.build(),
)
repository.getAlbumDetail("missing")
}
@Test
fun `search maps artists, albums, and songs from a single response`() = runTest {
server.enqueue(
MockResponse.Builder()
.body(
"""
{"subsonic-response":{"status":"ok","version":"1.16.1","searchResult3":{
"artist":[{"id":"2","name":"Calvin Harris","albumCount":2}],
"album":[{"id":"10","name":"Funk Wav Bounces","artist":"Calvin Harris"}],
"song":[{"id":"100","title":"Slide","artist":"Calvin Harris"}]
}}}
""".trimIndent(),
)
.build(),
)
val results = repository.search("calvin")
assertEquals(1, results.artists.size)
assertEquals(1, results.albums.size)
assertEquals(1, results.songs.size)
assertEquals("Slide", results.songs[0].title)
}
}
+8
View File
@@ -18,6 +18,9 @@ kotlinxSerializationJson = "1.9.0"
retrofitKotlinxSerializationConverter = "1.0.0"
androidxDatastorePreferences = "1.2.1"
tink = "1.23.0"
coil = "3.3.0"
mockitoCore = "5.23.0"
kotlinxCoroutinesTest = "1.11.0"
[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
@@ -47,6 +50,11 @@ okhttp-logging-interceptor = { group = "com.squareup.okhttp3", name = "logging-i
kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" }
androidx-datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "androidxDatastorePreferences" }
tink-android = { group = "com.google.crypto.tink", name = "tink-android", version.ref = "tink" }
coil-compose = { group = "io.coil-kt.coil3", name = "coil-compose", version.ref = "coil" }
coil-network-okhttp = { group = "io.coil-kt.coil3", name = "coil-network-okhttp", version.ref = "coil" }
okhttp-mockwebserver = { group = "com.squareup.okhttp3", name = "mockwebserver3", version.ref = "okhttp" }
mockito-core = { group = "org.mockito", name = "mockito-core", version.ref = "mockitoCore" }
kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "kotlinxCoroutinesTest" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }