Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 24 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@ Native Android client for [WaveFlow](https://github.com/InstaZDLL/WaveFlow) —
local-first music player. Kotlin + Jetpack Compose + Media3.

> **Status:** local-first. Plays, browses, searches and organises the device's
> own files. A WaveFlow server can be signed into; its catalogue is not exposed
> yet — see [Server](#server) for what is and isn't wired up.
> own files. A WaveFlow server can be signed into and its catalogue browsed;
> nothing streams from it yet — see [Server](#server).

## Stack

Expand Down Expand Up @@ -42,7 +42,7 @@ app/src/main/java/app/waveflow/
│ ├─ PlaylistRepository.kt Local playlist abstraction
│ ├─ RoomPlaylistRepository.kt Room-backed implementation
│ ├─ local/ Room entities, DAO, database
│ └─ remote/ WaveFlow server: auth API, session, tokens
│ └─ remote/ WaveFlow server: HTTP, auth, session, catalogue
├─ playback/
│ ├─ PlaybackService.kt Media3 MediaSessionService (ExoPlayer)
│ ├─ PlaybackController.kt Playback facade + PlaybackState
Expand Down Expand Up @@ -76,7 +76,8 @@ app/src/main/java/app/waveflow/
├─ server/
│ ├─ ServerViewModel.kt Sign in / out, error mapping
│ ├─ ServerUiState.kt Session + progress + last failure
│ └─ ServerScreen.kt Sign-in form, then the account
│ ├─ ServerScreen.kt Sign-in form and account screens
│ └─ catalog/ Remote albums / artists, paginated
├─ permission/
│ └─ AudioPermissionGate.kt Grant / deny / permanently-denied flow
├─ player/
Expand Down Expand Up @@ -134,6 +135,9 @@ in-memory SQLite for Room, so the DAO is exercised without a device.
| `ServerSessionRepositoryTest` | token refresh and rotation, session lifetime, sign-out |
| `ServerViewModelTest` | validation, error wording, connection progress |
| `ServerScreenTest` | sign-in form, connected account, no token on screen |
| `HttpCatalogApiTest` | paging params, flattened details, track ordering |
| `CatalogRepositoryTest` | token plumbing, retry after a refused token |
| `CatalogViewModelTest` | paging, end of list, in-flight guard, clear on sign-out |

Fakes and the `Dispatchers.Main` rule live in `src/test/java/app/waveflow/testing/`.

Expand All @@ -152,19 +156,29 @@ into `DragState` and tested there instead.
- [x] Search across songs, albums and artists
- [x] Compose UI tests (Robolectric, no device)
- [x] Sign in to a WaveFlow server (session, refresh, sign-out)
- [ ] Browse the server catalogue
- [x] Browse the server catalogue (albums, artists, paginated)
- [ ] Stream from the server
- [ ] Server user-data sync (playlists, favorites, ratings) — see below
- [ ] Android Auto (Media3 `MediaLibraryService`)

## Server

The **Server** tab signs in to a [WaveFlow
Server](https://github.com/InstaZDLL/waveflow-server) and keeps the session
alive. That is all it does so far: nothing of the server's catalogue is shown,
and nothing of the local library is sent anywhere. The two sources stay
separate by design — the tab is its own section rather than a filter over the
existing screens.
Server](https://github.com/InstaZDLL/waveflow-server), keeps the session alive
and browses its catalogue — albums, artists, and what each contains. Playback
is not wired up yet, so tapping a remote track does nothing. Nothing of the
local library is sent anywhere.

The two sources stay separate by design: the tab is its own section rather than
a filter over the existing screens, and `RemoteAlbum` / `RemoteArtist` /
`RemoteSong` are distinct types from their local counterparts. Their ids are
UUIDs rather than `MediaStore` integers, and nothing can currently say that a
remote track is the same file as a local one.

Listing endpoints return a bare array — no total, no cursor — so the end of a
list is inferred from a page shorter than requested. Cover art is not shown:
the v2 API exposes an `artwork_hash` but no endpoint serving the image; only
the Subsonic facade does, behind its own separate credential.

Sign-in posts to `/api/v2/auth/login` with the device model as the session
name, so the server lists it among the account's devices. The access token
Expand Down
112 changes: 107 additions & 5 deletions app/src/main/java/app/waveflow/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.AccountCircle
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.ExperimentalMaterial3Api
Expand Down Expand Up @@ -69,8 +70,13 @@ import app.waveflow.ui.playlists.PlaylistsViewModel
import app.waveflow.ui.search.SearchField
import app.waveflow.ui.search.SearchScreen
import app.waveflow.ui.search.SearchViewModel
import app.waveflow.ui.server.ServerScreen
import app.waveflow.ui.server.ServerAccountScreen
import app.waveflow.ui.server.ServerSignInScreen
import app.waveflow.ui.server.ServerViewModel
import app.waveflow.ui.server.catalog.CatalogViewModel
import app.waveflow.ui.server.catalog.RemoteAlbumDetailScreen
import app.waveflow.ui.server.catalog.RemoteArtistDetailScreen
import app.waveflow.ui.server.catalog.ServerCatalogScreen
import app.waveflow.ui.theme.WaveFlowTheme

class MainActivity : ComponentActivity() {
Expand All @@ -96,6 +102,9 @@ private val DETAIL_ROUTES = setOf(
Routes.ALBUM_DETAIL,
Routes.ARTIST_DETAIL,
Routes.PLAYLIST_DETAIL,
Routes.SERVER_ALBUM_DETAIL,
Routes.SERVER_ARTIST_DETAIL,
Routes.SERVER_ACCOUNT,
)

@OptIn(ExperimentalMaterial3Api::class)
Expand All @@ -106,13 +115,17 @@ private fun WaveFlowRoot() {
val playlistsViewModel: PlaylistsViewModel = viewModel(factory = PlaylistsViewModel.Factory)
val searchViewModel: SearchViewModel = viewModel(factory = SearchViewModel.Factory)
val serverViewModel: ServerViewModel = viewModel(factory = ServerViewModel.Factory)
val catalogViewModel: CatalogViewModel = viewModel(factory = CatalogViewModel.Factory)

val library by libraryViewModel.library.collectAsStateWithLifecycle()
val playerState by playerViewModel.state.collectAsStateWithLifecycle()
val playlistsState by playlistsViewModel.state.collectAsStateWithLifecycle()
val searchQuery by searchViewModel.query.collectAsStateWithLifecycle()
val searchResults by searchViewModel.results.collectAsStateWithLifecycle()
val serverState by serverViewModel.state.collectAsStateWithLifecycle()
// Les états du catalogue sont collectés dans leurs destinations, et non
// ici : chargés à la racine, chaque page reçue recomposerait le Scaffold,
// le NavHost et tous les écrans.

val navController = rememberNavController()
val backStackEntry by navController.currentBackStackEntryAsState()
Expand Down Expand Up @@ -222,6 +235,19 @@ private fun WaveFlowRoot() {
}
}

// Le compte n'a plus sa place dans l'onglet, que le
// catalogue occupe : il s'ouvre depuis ici.
if (currentRoute == Routes.SERVER && serverState.isConnected) {
IconButton(
onClick = { navController.navigate(Routes.SERVER_ACCOUNT) },
) {
Icon(
imageVector = Icons.Filled.AccountCircle,
contentDescription = "Compte du serveur",
)
}
}

openPlaylist?.let { playlist ->
PlaylistMenu(
playlist = playlist,
Expand Down Expand Up @@ -360,10 +386,83 @@ private fun WaveFlowRoot() {
}

composable(Routes.SERVER) {
ServerScreen(
state = serverState,
onConnect = serverViewModel::connect,
onDisconnect = serverViewModel::disconnect,
// Connecté, l'onglet montre le catalogue ; le compte
// devient un écran qu'on ouvre depuis la barre du haut.
val connected = serverState.connected
if (connected == null) {
ServerSignInScreen(
state = serverState,
onConnect = serverViewModel::connect,
bottomPadding = listBottomPadding,
)
} else {
val remoteAlbums by catalogViewModel.albums
.collectAsStateWithLifecycle()
val remoteArtists by catalogViewModel.artists
.collectAsStateWithLifecycle()

ServerCatalogScreen(
albums = remoteAlbums,
artists = remoteArtists,
onAlbumClick = {
navController.navigate(Routes.serverAlbumDetail(it.id))
},
onArtistClick = {
navController.navigate(Routes.serverArtistDetail(it.id))
},
onLoadMoreAlbums = catalogViewModel::loadMoreAlbums,
onLoadMoreArtists = catalogViewModel::loadMoreArtists,
onRetryAlbums = catalogViewModel::retryAlbums,
onRetryArtists = catalogViewModel::retryArtists,
bottomPadding = listBottomPadding,
)
}
}

composable(Routes.SERVER_ACCOUNT) {
serverState.connected?.let { session ->
ServerAccountScreen(
session = session,
onDisconnect = {
serverViewModel.disconnect()
navController.popBackStack()
},
bottomPadding = listBottomPadding,
)
}
}

composable(
route = Routes.SERVER_ALBUM_DETAIL,
arguments = listOf(navArgument(Routes.ARG_ALBUM_ID) { type = NavType.StringType }),
) { entry ->
val albumId = entry.arguments?.getString(Routes.ARG_ALBUM_ID)
?: return@composable
LaunchedEffect(albumId) { catalogViewModel.openAlbum(albumId) }
val detail by catalogViewModel.albumDetail.collectAsStateWithLifecycle()

RemoteAlbumDetailScreen(
state = detail,
onRetry = { catalogViewModel.openAlbum(albumId) },
bottomPadding = listBottomPadding,
)
}

composable(
route = Routes.SERVER_ARTIST_DETAIL,
arguments = listOf(navArgument(Routes.ARG_ARTIST_ID) { type = NavType.StringType }),
) { entry ->
val artistId = entry.arguments?.getString(Routes.ARG_ARTIST_ID)
?: return@composable
LaunchedEffect(artistId) { catalogViewModel.openArtist(artistId) }
val detail by catalogViewModel.artistDetail.collectAsStateWithLifecycle()

RemoteArtistDetailScreen(
state = detail,
onAlbumClick = {
navController.navigate(Routes.serverAlbumDetail(it.id))
},
onRetry = { catalogViewModel.openArtist(artistId) },
bottomPadding = listBottomPadding,
)
}
Expand Down Expand Up @@ -509,6 +608,9 @@ private fun currentScreenTitle(
Routes.ARTISTS -> "Artistes"
Routes.PLAYLISTS -> "Playlists"
Routes.SERVER -> "Serveur"
Routes.SERVER_ACCOUNT -> "Compte"
Routes.SERVER_ALBUM_DETAIL -> "Album"
Routes.SERVER_ARTIST_DETAIL -> "Artiste"
Routes.ALBUM_DETAIL -> albumId?.let { library.album(it)?.title } ?: "Album"
Routes.ARTIST_DETAIL -> artistId?.let { library.artist(it)?.name } ?: "Artiste"
Routes.PLAYLIST_DETAIL -> playlistName ?: "Playlist"
Expand Down
16 changes: 15 additions & 1 deletion app/src/main/java/app/waveflow/WaveFlowApp.kt
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,11 @@ import app.waveflow.data.MusicRepository
import app.waveflow.data.PlaylistRepository
import app.waveflow.data.RoomPlaylistRepository
import app.waveflow.data.local.WaveFlowDatabase
import app.waveflow.data.remote.CatalogRepository
import app.waveflow.data.remote.DataStoreSessionStore
import app.waveflow.data.remote.HttpCatalogApi
import app.waveflow.data.remote.HttpServerApi
import app.waveflow.data.remote.ServerHttp
import app.waveflow.data.remote.ServerSessionRepository
import app.waveflow.playback.Media3PlaybackController
import app.waveflow.playback.PlaybackController
Expand Down Expand Up @@ -64,6 +67,12 @@ class AppContainer(app: Application) {
*/
fun createPlaybackController(): PlaybackController = Media3PlaybackController(appContext)

/**
* Un seul transport pour tous les appels serveur : un pool de connexions
* partagé, et surtout un seul endroit qui classe les erreurs.
*/
private val serverHttp = ServerHttp()

/**
* Session serveur, indépendante de la bibliothèque locale : elle n'a besoin
* ni de la permission audio ni du MediaStore.
Expand All @@ -72,11 +81,16 @@ class AppContainer(app: Application) {
* sessions ; `Build.MODEL` est ce que l'utilisateur reconnaîtra.
*/
val serverSessionRepository = ServerSessionRepository(
api = HttpServerApi(),
api = HttpServerApi(serverHttp),
store = DataStoreSessionStore(app),
deviceName = Build.MODEL ?: "Android",
)

val catalogRepository = CatalogRepository(
api = HttpCatalogApi(serverHttp),
sessionRepository = serverSessionRepository,
)

/** Relit la session persistée, sans bloquer le démarrage. */
fun restoreServerSession() {
applicationScope.launch { serverSessionRepository.restore() }
Expand Down
46 changes: 46 additions & 0 deletions app/src/main/java/app/waveflow/data/remote/CatalogApi.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
package app.waveflow.data.remote

import app.waveflow.model.RemoteAlbum
import app.waveflow.model.RemoteAlbumDetail
import app.waveflow.model.RemoteArtist
import app.waveflow.model.RemoteArtistDetail

/**
* Lecture du catalogue d'un serveur WaveFlow.
*
* Chaque appel porte son jeton d'accès plutôt que d'aller le chercher : c'est
* à [CatalogRepository] de décider quand le renouveler, et ce découpage rend
* l'API testable sans session.
*/
interface CatalogApi {

/** `GET /api/v2/albums`, trié par le serveur. */
suspend fun albums(
serverUrl: String,
accessToken: String,
offset: Int,
limit: Int,
): List<RemoteAlbum>

/** `GET /api/v2/artists`. */
suspend fun artists(
serverUrl: String,
accessToken: String,
offset: Int,
limit: Int,
): List<RemoteArtist>

/** `GET /api/v2/albums/{id}` : l'album et ses morceaux d'un seul coup. */
suspend fun album(serverUrl: String, accessToken: String, albumId: String): RemoteAlbumDetail

/** `GET /api/v2/artists/{id}` : l'artiste et ses albums. */
suspend fun artist(serverUrl: String, accessToken: String, artistId: String): RemoteArtistDetail
}

/**
* Nombre d'éléments demandés par page.
*
* Le serveur refuse au-delà de 500 ; on reste bien en deçà, une page devant
* arriver assez vite pour que le défilement ne marque pas d'arrêt.
*/
const val CATALOG_PAGE_SIZE = 50
Loading