diff --git a/.idea/deviceManager.xml b/.idea/deviceManager.xml
new file mode 100644
index 0000000..91f9558
--- /dev/null
+++ b/.idea/deviceManager.xml
@@ -0,0 +1,13 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index bdc7016..1a249dd 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -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)
diff --git a/app/src/main/java/com/InfernalAquatics/deepwave/DeepwaveApplication.kt b/app/src/main/java/com/InfernalAquatics/deepwave/DeepwaveApplication.kt
index c88815d..b9201f0 100644
--- a/app/src/main/java/com/InfernalAquatics/deepwave/DeepwaveApplication.kt
+++ b/app/src/main/java/com/InfernalAquatics/deepwave/DeepwaveApplication.kt
@@ -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()
+ }
+}
diff --git a/app/src/main/java/com/InfernalAquatics/deepwave/data/model/LibraryModels.kt b/app/src/main/java/com/InfernalAquatics/deepwave/data/model/LibraryModels.kt
new file mode 100644
index 0000000..8cd9ee9
--- /dev/null
+++ b/app/src/main/java/com/InfernalAquatics/deepwave/data/model/LibraryModels.kt
@@ -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?,
+)
diff --git a/app/src/main/java/com/InfernalAquatics/deepwave/data/network/CoverArtUrls.kt b/app/src/main/java/com/InfernalAquatics/deepwave/data/network/CoverArtUrls.kt
new file mode 100644
index 0000000..7cd90b3
--- /dev/null
+++ b/app/src/main/java/com/InfernalAquatics/deepwave/data/network/CoverArtUrls.kt
@@ -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" }
diff --git a/app/src/main/java/com/InfernalAquatics/deepwave/data/network/SubsonicApi.kt b/app/src/main/java/com/InfernalAquatics/deepwave/data/network/SubsonicApi.kt
index b647ec7..ce805a9 100644
--- a/app/src/main/java/com/InfernalAquatics/deepwave/data/network/SubsonicApi.kt
+++ b/app/src/main/java/com/InfernalAquatics/deepwave/data/network/SubsonicApi.kt
@@ -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
}
diff --git a/app/src/main/java/com/InfernalAquatics/deepwave/data/network/model/AlbumModels.kt b/app/src/main/java/com/InfernalAquatics/deepwave/data/network/model/AlbumModels.kt
new file mode 100644
index 0000000..093ee7b
--- /dev/null
+++ b/app/src/main/java/com/InfernalAquatics/deepwave/data/network/model/AlbumModels.kt
@@ -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 = 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 = emptyList(),
+)
diff --git a/app/src/main/java/com/InfernalAquatics/deepwave/data/network/model/ArtistModels.kt b/app/src/main/java/com/InfernalAquatics/deepwave/data/network/model/ArtistModels.kt
new file mode 100644
index 0000000..1da8862
--- /dev/null
+++ b/app/src/main/java/com/InfernalAquatics/deepwave/data/network/model/ArtistModels.kt
@@ -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 = emptyList(),
+)
+
+@Serializable
+data class ArtistIndexEntryDto(
+ val name: String,
+ val artist: List = 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 = emptyList(),
+)
diff --git a/app/src/main/java/com/InfernalAquatics/deepwave/data/network/model/SearchModels.kt b/app/src/main/java/com/InfernalAquatics/deepwave/data/network/model/SearchModels.kt
new file mode 100644
index 0000000..4157b11
--- /dev/null
+++ b/app/src/main/java/com/InfernalAquatics/deepwave/data/network/model/SearchModels.kt
@@ -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 = emptyList(),
+ val album: List = emptyList(),
+ val song: List = emptyList(),
+)
diff --git a/app/src/main/java/com/InfernalAquatics/deepwave/data/network/model/SongModels.kt b/app/src/main/java/com/InfernalAquatics/deepwave/data/network/model/SongModels.kt
new file mode 100644
index 0000000..639ace8
--- /dev/null
+++ b/app/src/main/java/com/InfernalAquatics/deepwave/data/network/model/SongModels.kt
@@ -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,
+)
diff --git a/app/src/main/java/com/InfernalAquatics/deepwave/data/repository/LibraryRepository.kt b/app/src/main/java/com/InfernalAquatics/deepwave/data/repository/LibraryRepository.kt
new file mode 100644
index 0000000..89e1800
--- /dev/null
+++ b/app/src/main/java/com/InfernalAquatics/deepwave/data/repository/LibraryRepository.kt
@@ -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,
+ val albums: List,
+ val songs: List,
+)
+
+/** 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 =
+ subsonicApi.getArtists().subsonicResponse.artists?.index.orEmpty()
+ .flatMap { it.artist }
+ .map { it.toDomain() }
+
+ suspend fun getArtistDetail(artistId: String): Pair> {
+ 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 =
+ subsonicApi.getAlbumList2(type = type, size = size).subsonicResponse.albumList2?.album.orEmpty()
+ .map { it.toDomain() }
+
+ suspend fun getAlbumDetail(albumId: String): Pair> {
+ 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,
+)
diff --git a/app/src/main/java/com/InfernalAquatics/deepwave/di/NetworkModule.kt b/app/src/main/java/com/InfernalAquatics/deepwave/di/NetworkModule.kt
index d86ae77..d7e6c38 100644
--- a/app/src/main/java/com/InfernalAquatics/deepwave/di/NetworkModule.kt
+++ b/app/src/main/java/com/InfernalAquatics/deepwave/di/NetworkModule.kt
@@ -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)
diff --git a/app/src/main/java/com/InfernalAquatics/deepwave/di/OkHttpClientEntryPoint.kt b/app/src/main/java/com/InfernalAquatics/deepwave/di/OkHttpClientEntryPoint.kt
new file mode 100644
index 0000000..94674d6
--- /dev/null
+++ b/app/src/main/java/com/InfernalAquatics/deepwave/di/OkHttpClientEntryPoint.kt
@@ -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
+}
diff --git a/app/src/main/java/com/InfernalAquatics/deepwave/ui/components/AlbumCard.kt b/app/src/main/java/com/InfernalAquatics/deepwave/ui/components/AlbumCard.kt
index 66059f6..5f3fa95 100644
--- a/app/src/main/java/com/InfernalAquatics/deepwave/ui/components/AlbumCard.kt
+++ b/app/src/main/java/com/InfernalAquatics/deepwave/ui/components/AlbumCard.kt
@@ -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,
diff --git a/app/src/main/java/com/InfernalAquatics/deepwave/ui/components/ArtistCard.kt b/app/src/main/java/com/InfernalAquatics/deepwave/ui/components/ArtistCard.kt
index 4a50224..9d01d7f 100644
--- a/app/src/main/java/com/InfernalAquatics/deepwave/ui/components/ArtistCard.kt
+++ b/app/src/main/java/com/InfernalAquatics/deepwave/ui/components/ArtistCard.kt
@@ -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,
diff --git a/app/src/main/java/com/InfernalAquatics/deepwave/ui/components/Artwork.kt b/app/src/main/java/com/InfernalAquatics/deepwave/ui/components/Artwork.kt
new file mode 100644
index 0000000..ae2bf1a
--- /dev/null
+++ b/app/src/main/java/com/InfernalAquatics/deepwave/ui/components/Artwork.kt
@@ -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)
+ }
+ }
+}
diff --git a/app/src/main/java/com/InfernalAquatics/deepwave/ui/components/TrackRow.kt b/app/src/main/java/com/InfernalAquatics/deepwave/ui/components/TrackRow.kt
index a3a8358..7bdf348 100644
--- a/app/src/main/java/com/InfernalAquatics/deepwave/ui/components/TrackRow.kt
+++ b/app/src/main/java/com/InfernalAquatics/deepwave/ui/components/TrackRow.kt
@@ -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)
diff --git a/app/src/main/java/com/InfernalAquatics/deepwave/ui/home/HomeScreen.kt b/app/src/main/java/com/InfernalAquatics/deepwave/ui/home/HomeScreen.kt
deleted file mode 100644
index 1b39e05..0000000
--- a/app/src/main/java/com/InfernalAquatics/deepwave/ui/home/HomeScreen.kt
+++ /dev/null
@@ -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()
- }
-}
diff --git a/app/src/main/java/com/InfernalAquatics/deepwave/ui/library/AlbumDetailScreen.kt b/app/src/main/java/com/InfernalAquatics/deepwave/ui/library/AlbumDetailScreen.kt
new file mode 100644
index 0000000..bb88a6a
--- /dev/null
+++ b/app/src/main/java/com/InfernalAquatics/deepwave/ui/library/AlbumDetailScreen.kt
@@ -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 = {},
+ )
+ }
+}
diff --git a/app/src/main/java/com/InfernalAquatics/deepwave/ui/library/AlbumDetailViewModel.kt b/app/src/main/java/com/InfernalAquatics/deepwave/ui/library/AlbumDetailViewModel.kt
new file mode 100644
index 0000000..13fbbba
--- /dev/null
+++ b/app/src/main/java/com/InfernalAquatics/deepwave/ui/library/AlbumDetailViewModel.kt
@@ -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 = emptyList(),
+ val errorMessage: String? = null,
+)
+
+@HiltViewModel
+class AlbumDetailViewModel @Inject constructor(
+ savedStateHandle: SavedStateHandle,
+ private val libraryRepository: LibraryRepository,
+) : ViewModel() {
+ private val albumId = savedStateHandle.toRoute().albumId
+
+ private val _uiState = MutableStateFlow(AlbumDetailUiState())
+ val uiState: StateFlow = _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) }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/InfernalAquatics/deepwave/ui/library/ArtistDetailScreen.kt b/app/src/main/java/com/InfernalAquatics/deepwave/ui/library/ArtistDetailScreen.kt
new file mode 100644
index 0000000..ce7a7bd
--- /dev/null
+++ b/app/src/main/java/com/InfernalAquatics/deepwave/ui/library/ArtistDetailScreen.kt
@@ -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 = {},
+ )
+ }
+}
diff --git a/app/src/main/java/com/InfernalAquatics/deepwave/ui/library/ArtistDetailViewModel.kt b/app/src/main/java/com/InfernalAquatics/deepwave/ui/library/ArtistDetailViewModel.kt
new file mode 100644
index 0000000..34217fa
--- /dev/null
+++ b/app/src/main/java/com/InfernalAquatics/deepwave/ui/library/ArtistDetailViewModel.kt
@@ -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 = emptyList(),
+ val errorMessage: String? = null,
+)
+
+@HiltViewModel
+class ArtistDetailViewModel @Inject constructor(
+ savedStateHandle: SavedStateHandle,
+ private val libraryRepository: LibraryRepository,
+) : ViewModel() {
+ private val artistId = savedStateHandle.toRoute().artistId
+
+ private val _uiState = MutableStateFlow(ArtistDetailUiState())
+ val uiState: StateFlow = _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) }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/InfernalAquatics/deepwave/ui/library/ArtistsScreen.kt b/app/src/main/java/com/InfernalAquatics/deepwave/ui/library/ArtistsScreen.kt
new file mode 100644
index 0000000..2d9f2e4
--- /dev/null
+++ b/app/src/main/java/com/InfernalAquatics/deepwave/ui/library/ArtistsScreen.kt
@@ -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 = {})
+ }
+}
diff --git a/app/src/main/java/com/InfernalAquatics/deepwave/ui/library/ArtistsViewModel.kt b/app/src/main/java/com/InfernalAquatics/deepwave/ui/library/ArtistsViewModel.kt
new file mode 100644
index 0000000..2994f18
--- /dev/null
+++ b/app/src/main/java/com/InfernalAquatics/deepwave/ui/library/ArtistsViewModel.kt
@@ -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 = emptyList(),
+ val errorMessage: String? = null,
+)
+
+@HiltViewModel
+class ArtistsViewModel @Inject constructor(
+ private val libraryRepository: LibraryRepository,
+) : ViewModel() {
+ private val _uiState = MutableStateFlow(ArtistsUiState())
+ val uiState: StateFlow = _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()}") }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/InfernalAquatics/deepwave/ui/library/HomeScreen.kt b/app/src/main/java/com/InfernalAquatics/deepwave/ui/library/HomeScreen.kt
new file mode 100644
index 0000000..4aed5e7
--- /dev/null
+++ b/app/src/main/java/com/InfernalAquatics/deepwave/ui/library/HomeScreen.kt
@@ -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, 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 = {})
+ }
+}
diff --git a/app/src/main/java/com/InfernalAquatics/deepwave/ui/library/HomeViewModel.kt b/app/src/main/java/com/InfernalAquatics/deepwave/ui/library/HomeViewModel.kt
new file mode 100644
index 0000000..6edbdd8
--- /dev/null
+++ b/app/src/main/java/com/InfernalAquatics/deepwave/ui/library/HomeViewModel.kt
@@ -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 = emptyList(),
+ val recentlyPlayed: List = emptyList(),
+ val madeForYou: List = emptyList(),
+ val errorMessage: String? = null,
+)
+
+@HiltViewModel
+class HomeViewModel @Inject constructor(
+ private val libraryRepository: LibraryRepository,
+) : ViewModel() {
+ private val _uiState = MutableStateFlow(HomeUiState())
+ val uiState: StateFlow = _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()}") }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/InfernalAquatics/deepwave/ui/library/LibraryScreen.kt b/app/src/main/java/com/InfernalAquatics/deepwave/ui/library/LibraryScreen.kt
deleted file mode 100644
index a8546ed..0000000
--- a/app/src/main/java/com/InfernalAquatics/deepwave/ui/library/LibraryScreen.kt
+++ /dev/null
@@ -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()
- }
-}
diff --git a/app/src/main/java/com/InfernalAquatics/deepwave/ui/library/SearchScreen.kt b/app/src/main/java/com/InfernalAquatics/deepwave/ui/library/SearchScreen.kt
new file mode 100644
index 0000000..ec460e6
--- /dev/null
+++ b/app/src/main/java/com/InfernalAquatics/deepwave/ui/library/SearchScreen.kt
@@ -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 = {},
+ )
+ }
+}
diff --git a/app/src/main/java/com/InfernalAquatics/deepwave/ui/library/SearchViewModel.kt b/app/src/main/java/com/InfernalAquatics/deepwave/ui/library/SearchViewModel.kt
new file mode 100644
index 0000000..5120fbb
--- /dev/null
+++ b/app/src/main/java/com/InfernalAquatics/deepwave/ui/library/SearchViewModel.kt
@@ -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 = _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()}") }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/InfernalAquatics/deepwave/ui/navigation/DeepwaveNavHost.kt b/app/src/main/java/com/InfernalAquatics/deepwave/ui/navigation/DeepwaveNavHost.kt
index 22d41f7..1318cad 100644
--- a/app/src/main/java/com/InfernalAquatics/deepwave/ui/navigation/DeepwaveNavHost.kt
+++ b/app/src/main/java/com/InfernalAquatics/deepwave/ui/navigation/DeepwaveNavHost.kt
@@ -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 {
- 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 {
SettingsScreen(
@@ -53,6 +59,15 @@ fun DeepwaveNavHost(
},
)
}
+ composable {
+ ArtistDetailScreen(
+ onBack = { navController.popBackStack() },
+ onAlbumClick = { albumId -> navController.navigate(Route.AlbumDetail(albumId)) },
+ )
+ }
+ composable {
+ AlbumDetailScreen(onBack = { navController.popBackStack() })
+ }
}
}
}
diff --git a/app/src/main/java/com/InfernalAquatics/deepwave/ui/navigation/MainScreen.kt b/app/src/main/java/com/InfernalAquatics/deepwave/ui/navigation/MainScreen.kt
index 112b648..8aa96e7 100644
--- a/app/src/main/java/com/InfernalAquatics/deepwave/ui/navigation/MainScreen.kt
+++ b/app/src/main/java/com/InfernalAquatics/deepwave/ui/navigation/MainScreen.kt
@@ -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 = {})
}
}
diff --git a/app/src/main/java/com/InfernalAquatics/deepwave/ui/navigation/Route.kt b/app/src/main/java/com/InfernalAquatics/deepwave/ui/navigation/Route.kt
index 2ddc217..935d09e 100644
--- a/app/src/main/java/com/InfernalAquatics/deepwave/ui/navigation/Route.kt
+++ b/app/src/main/java/com/InfernalAquatics/deepwave/ui/navigation/Route.kt
@@ -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
}
diff --git a/app/src/main/java/com/InfernalAquatics/deepwave/ui/search/SearchScreen.kt b/app/src/main/java/com/InfernalAquatics/deepwave/ui/search/SearchScreen.kt
deleted file mode 100644
index c87b14b..0000000
--- a/app/src/main/java/com/InfernalAquatics/deepwave/ui/search/SearchScreen.kt
+++ /dev/null
@@ -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()
- }
-}
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 94d668c..76e8949 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -18,13 +18,16 @@
Settings
Log out
- Search coming soon
- Your library coming soon
-
Nothing playing
Play
Recently Added
Recently Played
Made For You
+
+ Artists, albums, songs
+ No results
+ Artists
+ Albums
+ Songs
diff --git a/app/src/test/java/com/InfernalAquatics/deepwave/data/network/SubsonicRequestInterceptorTest.kt b/app/src/test/java/com/InfernalAquatics/deepwave/data/network/SubsonicRequestInterceptorTest.kt
new file mode 100644
index 0000000..d8c94e2
--- /dev/null
+++ b/app/src/test/java/com/InfernalAquatics/deepwave/data/network/SubsonicRequestInterceptorTest.kt
@@ -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"))
+ }
+}
diff --git a/app/src/test/java/com/InfernalAquatics/deepwave/data/repository/LibraryRepositoryTest.kt b/app/src/test/java/com/InfernalAquatics/deepwave/data/repository/LibraryRepositoryTest.kt
new file mode 100644
index 0000000..5954a31
--- /dev/null
+++ b/app/src/test/java/com/InfernalAquatics/deepwave/data/repository/LibraryRepositoryTest.kt
@@ -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)
+ }
+}
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 193b31d..bd44a16 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -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" }