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
26 changes: 18 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,7 @@ 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 and its catalogue browsed;
> nothing streams from it yet — see [Server](#server).
> own files, and streams from a WaveFlow server — see [Server](#server).

## Stack

Expand Down Expand Up @@ -47,7 +46,9 @@ app/src/main/java/app/waveflow/
│ ├─ PlaybackService.kt Media3 MediaSessionService (ExoPlayer)
│ ├─ PlaybackController.kt Playback facade + PlaybackState
│ ├─ Media3PlaybackController.kt MediaController connection → StateFlow
│ └─ MediaItemMapper.kt Song ↔ MediaItem
│ ├─ MediaItemMapper.kt Song / RemoteSong → MediaItem, and back
│ ├─ PlayingTrack.kt What the player holds, whatever its source
│ └─ RemoteStreamResolver.kt Marker URI → ticketed stream URL
└─ ui/
├─ theme/ Material 3 emerald theme
├─ DurationFormat.kt m:ss / h:mm:ss
Expand Down Expand Up @@ -123,7 +124,7 @@ in-memory SQLite for Room, so the DAO is exercised without a device.
|---|---|
| `PlaylistDaoTest` | duplicate adds, `updatedAt` bumping, positions, `reorder` normalisation, `createWithSong` atomicity, cascade delete |
| `LibraryStoreTest` | loading, read failures, single subscription, retry |
| `PlayerViewModelTest` | contextual play queue, current-song resolution, controller release |
| `PlayerViewModelTest` | contextual play queue, local vs remote queue, controller release |
| `PlaylistsViewModelTest` | flow failures, write failures, resolution order, atomic creation, reorder rollback and staleness |
| `DragStateTest` | drag arithmetic: target rank, visual offset, bounds, `moved` |
| `PlaylistDetailScreenTest` | reorder accessibility actions, order restored after a failed write |
Expand All @@ -138,6 +139,8 @@ in-memory SQLite for Room, so the DAO is exercised without a device.
| `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 |
| `MediaItemMapperTest` | local vs remote track identity, unreachable marker URI |
| `RemoteStreamResolverTest` | ticket swap, local passthrough, DataSpec preserved |

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

Expand All @@ -157,24 +160,31 @@ into `DragState` and tested there instead.
- [x] Compose UI tests (Robolectric, no device)
- [x] Sign in to a WaveFlow server (session, refresh, sign-out)
- [x] Browse the server catalogue (albums, artists, paginated)
- [ ] Stream from the server
- [x] Stream from the server (ticketed URLs, seeking)
- [ ] 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), 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.
and browses its catalogue — albums, artists, and what each contains. Tapping a
remote track plays it. 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.

Playback goes through a **stream ticket**: `POST /tracks/{id}/stream-ticket`
returns a URL that needs no `Authorization` header, which is what lets ExoPlayer
consume it directly — range requests for seeking included. The ticket is minted
when the player opens the track, not when the queue is built: it lives an hour,
and a long queue would outlast it before reaching its last tracks. A
`ResolvingDataSource` does the swap, so local files and remote tracks share one
player and one queue mechanism.

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
Expand Down
12 changes: 10 additions & 2 deletions app/src/main/java/app/waveflow/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -142,8 +142,8 @@ private fun WaveFlowRoot() {
searchViewModel.clear()
}

val nowPlayingId = playerState.song?.id
val hasTrack = playerState.song != null
val nowPlayingId = playerState.track?.localSongId
val hasTrack = playerState.track != null

// Les écritures de playlist qui échouent se signalent une fois, sans
// remplacer le contenu de l'écran.
Expand Down Expand Up @@ -443,6 +443,14 @@ private fun WaveFlowRoot() {

RemoteAlbumDetailScreen(
state = detail,
nowPlayingMediaId = playerState.track?.mediaId,
// La file de lecture est l'album affiché, comme
// pour un album local.
onSongClick = { song ->
detail.value?.songs?.let {
playerViewModel.playRemoteFrom(it, song)
}
},
onRetry = { catalogViewModel.openAlbum(albumId) },
bottomPadding = listBottomPadding,
)
Expand Down
10 changes: 10 additions & 0 deletions app/src/main/java/app/waveflow/data/remote/CatalogApi.kt
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,16 @@ interface CatalogApi {

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

/**
* `POST /api/v2/tracks/{id}/stream-ticket`.
*
* Rend une URL de diffusion **absolue**, qui ne demande aucun en-tête
* d'autorisation — le serveur la rend relative, elle est résolue ici contre
* [serverUrl]. C'est ce qui permet de la confier telle quelle à ExoPlayer,
* y compris pour les requêtes de plage d'un déplacement dans le morceau.
*/
suspend fun streamTicket(serverUrl: String, accessToken: String, trackId: String): String
}

/**
Expand Down
10 changes: 10 additions & 0 deletions app/src/main/java/app/waveflow/data/remote/CatalogRepository.kt
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,16 @@ class CatalogRepository(
suspend fun artist(artistId: String): RemoteArtistDetail =
authorized { url, token -> api.artist(url, token, artistId) }

/**
* URL de diffusion d'une piste, valable une heure côté serveur.
*
* Demandée au moment de lire, et non à la constitution de la file : une
* longue file dépasserait l'échéance avant d'atteindre ses derniers
* morceaux.
*/
suspend fun streamUrl(trackId: String): String =
authorized { url, token -> api.streamTicket(url, token, trackId) }

/**
* Exécute [call] avec un jeton valide, en réessayant une fois sur refus.
*
Expand Down
7 changes: 7 additions & 0 deletions app/src/main/java/app/waveflow/data/remote/Dto.kt
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,13 @@ internal data class AlbumDetailResponse(
get() = AlbumResponse(id, title, artist, artistId, year)
}

/** `{"url": "/api/v2/stream/<ticket>", "expires_at": <ms>}` — l'URL est relative. */
@Serializable
internal data class StreamTicketResponse(
val url: String,
@SerialName("expires_at") val expiresAt: Long,
)

@Serializable
internal data class ArtistDetailResponse(
val id: String,
Expand Down
16 changes: 16 additions & 0 deletions app/src/main/java/app/waveflow/data/remote/HttpCatalogApi.kt
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,21 @@ class HttpCatalogApi(
)
}

override suspend fun streamTicket(
serverUrl: String,
accessToken: String,
trackId: String,
): String {
val ticket = http.post(
serverUrl = serverUrl,
path = "$TRACKS/$trackId/stream-ticket",
body = "{}",
accessToken = accessToken,
).decode<StreamTicketResponse>()

return http.absoluteUrl(serverUrl, ticket.url)
}

private fun pageQuery(offset: Int, limit: Int) = mapOf(
"offset" to offset.toString(),
"limit" to limit.toString(),
Expand All @@ -83,6 +98,7 @@ class HttpCatalogApi(
private companion object {
const val ALBUMS = "api/v2/albums"
const val ARTISTS = "api/v2/artists"
const val TRACKS = "api/v2/tracks"

/** Sans numéro de piste, on retombe sur le titre plutôt que sur rien. */
val BY_TRACK_THEN_TITLE = compareBy<app.waveflow.model.RemoteSong>(
Expand Down
23 changes: 23 additions & 0 deletions app/src/main/java/app/waveflow/data/remote/ServerHttp.kt
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,29 @@ class ServerHttp(
.build()
}

/**
* Résout un chemin rendu par le serveur contre l'adresse de celui-ci.
*
* Le ticket de diffusion arrive sous la forme `/api/v2/stream/<ticket>`.
* Le passer à `resolve` écraserait le chemin de base : un serveur derrière
* un proxy qui le préfixe verrait son préfixe disparaître. Il est donc
* traité comme n'importe quel chemin d'API, par la même construction que
* les appels — qui, elle, conserve le préfixe.
*
* Seul un chemin absolu du serveur est accepté. Une URL complète ou une
* référence réseau (`//hôte/…`) désignerait un autre hôte que celui où
* l'utilisateur s'est authentifié.
*/
fun absoluteUrl(serverUrl: String, path: String): String {
if (!path.startsWith("/") || path.startsWith("//")) {
throw ServerException.Unexpected("Chemin de diffusion inattendu : $path")
}

return serverUrl
.toApiUrl(path = path.removePrefix("/"), pathSegment = null, query = emptyMap())
.toString()
}

private fun Response.toException(): ServerException {
// Le serveur répond `{code, message}` sur ses erreurs métier, mais un
// corps mal formé lui fait renvoyer du texte brut : lire le message
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import androidx.media3.common.C
import androidx.media3.common.Player
import androidx.media3.session.MediaController
import androidx.media3.session.SessionToken
import app.waveflow.model.RemoteSong
import app.waveflow.model.Song
import com.google.common.util.concurrent.ListenableFuture
import kotlinx.coroutines.CoroutineScope
Expand Down Expand Up @@ -90,6 +91,16 @@ class Media3PlaybackController(
ctrl.play()
}

override fun playRemote(songs: List<RemoteSong>, startIndex: Int) {
val ctrl = controller ?: return
if (songs.isEmpty()) return

ctrl.shuffleModeEnabled = false
ctrl.setMediaItems(songs.map { it.toMediaItem() }, startIndex.coerceIn(songs.indices), 0L)
ctrl.prepare()
ctrl.play()
}

override fun playShuffled(songs: List<Song>) {
val ctrl = controller ?: return
if (songs.isEmpty()) return
Expand Down Expand Up @@ -145,7 +156,7 @@ class Media3PlaybackController(
private fun syncFrom(player: Player) {
_state.value = PlaybackState(
isConnected = true,
currentSongId = player.currentMediaItem?.songId,
current = player.currentMediaItem?.toPlayingTrack(),
isPlaying = player.isPlaying,
positionMs = player.currentPosition.coerceAtLeast(0L),
durationMs = player.duration.takeIf { it != C.TIME_UNSET }?.coerceAtLeast(0L) ?: 0L,
Expand Down
70 changes: 62 additions & 8 deletions app/src/main/java/app/waveflow/playback/MediaItemMapper.kt
Original file line number Diff line number Diff line change
@@ -1,19 +1,22 @@
package app.waveflow.playback

import androidx.core.net.toUri
import androidx.media3.common.MediaItem
import androidx.media3.common.MediaMetadata
import app.waveflow.model.RemoteSong
import app.waveflow.model.Song

/**
* Traduction [Song] <-> [MediaItem].
* Traduction des morceaux vers [MediaItem], et retour.
*
* Le `mediaId` porte l'identifiant du morceau : c'est le seul lien entre ce
* que joue Media3 et le modèle de l'application, ce qui permet de retrouver
* la piste courante sans garder de référence côté lecteur.
* Le `mediaId` est préfixé par sa source. C'est le seul lien entre ce que joue
* Media3 et le modèle de l'application ; le préfixe évite qu'un identifiant
* MediaStore et un UUID distant se confondent, et permet de reconnaître une
* piste locale sans consulter la bibliothèque.
*/
fun Song.toMediaItem(): MediaItem =
MediaItem.Builder()
.setMediaId(id.toString())
.setMediaId("$LOCAL_PREFIX$id")
.setUri(uri)
.setMediaMetadata(
MediaMetadata.Builder()
Expand All @@ -25,6 +28,57 @@ fun Song.toMediaItem(): MediaItem =
)
.build()

/** Identifiant [Song] porté par ce [MediaItem], ou `null` s'il vient d'ailleurs. */
val MediaItem.songId: Long?
get() = mediaId.toLongOrNull()
/**
* Piste distante, dont l'URI n'est **pas** joignable telle quelle.
*
* Le schéma `waveflow` est un marqueur : [RemoteStreamResolver] l'échange
* contre une URL de diffusion au moment où le lecteur ouvre la piste. Frapper
* le serveur ici, à la construction de la file, périmerait les tickets des
* derniers morceaux avant qu'on ne les atteigne.
*/
fun RemoteSong.toMediaItem(): MediaItem =
MediaItem.Builder()
.setMediaId("$REMOTE_PREFIX$id")
.setUri("$REMOTE_SCHEME://track/$id".toUri())
.setMediaMetadata(
MediaMetadata.Builder()
.setTitle(title)
.setArtist(artist)
.setAlbumTitle(album)
.build(),
)
.build()

/** Ce que le lecteur donne à voir de sa piste courante. */
fun MediaItem.toPlayingTrack(): PlayingTrack = PlayingTrack(
mediaId = mediaId,
title = mediaMetadata.title?.toString().orEmpty(),
artist = mediaMetadata.artist?.toString(),
album = mediaMetadata.albumTitle?.toString(),
artworkUri = mediaMetadata.artworkUri,
localSongId = localSongId,
source = if (mediaId.startsWith(REMOTE_PREFIX)) TrackSource.Remote else TrackSource.Local,
)

/**
* Identité de cette piste distante dans la file de lecture.
*
* Permet à un écran de reconnaître la ligne en cours sans construire de
* [MediaItem] : c'est la même clé que [PlayingTrack.mediaId].
*/
val RemoteSong.mediaId: String
get() = "$REMOTE_PREFIX$id"

/** Identifiant MediaStore porté par ce [MediaItem], ou `null` s'il vient d'ailleurs. */
val MediaItem.localSongId: Long?
get() = mediaId.removePrefix(LOCAL_PREFIX).takeIf { mediaId.startsWith(LOCAL_PREFIX) }?.toLongOrNull()

/** Identifiant de piste serveur, ou `null` si la piste est locale. */
internal fun trackIdOfRemoteUri(uri: android.net.Uri): String? =
uri.lastPathSegment?.takeIf { uri.scheme == REMOTE_SCHEME }

private const val LOCAL_PREFIX = "local:"
private const val REMOTE_PREFIX = "remote:"

/** Schéma interne : aucune pile réseau ne sait le résoudre, et c'est voulu. */
internal const val REMOTE_SCHEME = "waveflow"
15 changes: 13 additions & 2 deletions app/src/main/java/app/waveflow/playback/PlaybackController.kt
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package app.waveflow.playback

import app.waveflow.model.RemoteSong
import app.waveflow.model.Song
import kotlinx.coroutines.flow.StateFlow

Expand All @@ -20,7 +21,8 @@ enum class RepeatMode {
*
* @property isConnected `true` une fois la liaison au service établie ; tant
* qu'il est `false`, les commandes sont ignorées.
* @property currentSongId identifiant du morceau courant, `null` si la file est vide.
* @property current morceau courant, `null` si la file est vide. Décrit par ce
* que le lecteur en sait : il peut venir de l'appareil comme d'un serveur.
* @property isPlaying lecture réellement en cours (pas seulement demandée).
* @property positionMs position de lecture en millisecondes.
* @property durationMs durée du morceau courant, 0 si inconnue.
Expand All @@ -29,7 +31,7 @@ enum class RepeatMode {
*/
data class PlaybackState(
val isConnected: Boolean = false,
val currentSongId: Long? = null,
val current: PlayingTrack? = null,
val isPlaying: Boolean = false,
val positionMs: Long = 0L,
val durationMs: Long = 0L,
Expand All @@ -54,6 +56,15 @@ interface PlaybackController {
/** Charge [songs] comme file d'attente et démarre à [startIndex]. */
fun play(songs: List<Song>, startIndex: Int)

/**
* Même chose pour des morceaux du serveur.
*
* File distincte plutôt que mêlée à la locale : les deux sources sont
* séparées partout ailleurs dans l'app, et rien ne permet de dire qu'une
* piste distante et une piste locale sont le même enregistrement.
*/
fun playRemote(songs: List<RemoteSong>, startIndex: Int)

/**
* Charge [songs] en activant la lecture aléatoire et démarre sur un
* morceau au hasard.
Expand Down
Loading