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
16 changes: 13 additions & 3 deletions app/src/main/java/app/waveflow/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ import app.waveflow.ui.playlists.PlaylistDetailScreen
import app.waveflow.ui.playlists.PlaylistMenu
import app.waveflow.ui.playlists.PlaylistsScreen
import app.waveflow.ui.playlists.PlaylistsViewModel
import app.waveflow.ui.quality.StreamQualityViewModel
import app.waveflow.ui.search.SearchField
import app.waveflow.ui.search.SearchScreen
import app.waveflow.ui.search.SearchViewModel
Expand Down Expand Up @@ -134,6 +135,7 @@ private fun WaveFlowRoot(
val serverViewModel: ServerViewModel = viewModel(factory = ServerViewModel.Factory)
val catalogViewModel: CatalogViewModel = viewModel(factory = CatalogViewModel.Factory)
val cacheViewModel: CacheViewModel = viewModel(factory = CacheViewModel.Factory)
val qualityViewModel: StreamQualityViewModel = viewModel(factory = StreamQualityViewModel.Factory)

val library by libraryViewModel.library.collectAsStateWithLifecycle()
val playerState by playerViewModel.state.collectAsStateWithLifecycle()
Expand Down Expand Up @@ -525,17 +527,25 @@ private fun WaveFlowRoot(

composable(Routes.SERVER_ACCOUNT) {
serverState.connected?.let { session ->
// La taille bouge à chaque piste lue : elle se
// relève à l'ouverture, pas une fois pour toutes.
LaunchedEffect(Unit) { cacheViewModel.refresh() }
val qualityState by qualityViewModel.state.collectAsStateWithLifecycle()

// La taille bouge à chaque piste lue, et ffmpeg peut
// avoir été installé ou retiré du serveur : les deux
// se relèvent à l'ouverture, pas une fois pour toutes.
LaunchedEffect(Unit) {
cacheViewModel.refresh()
qualityViewModel.refresh()
}

ServerAccountScreen(
session = session,
quality = qualityState,
cache = cacheState,
onDisconnect = {
serverViewModel.disconnect()
navController.popBackStack()
},
onChooseQuality = qualityViewModel::choose,
onClearCache = cacheViewModel::clear,
onDismissCacheError = cacheViewModel::dismissError,
bottomPadding = listBottomPadding,
Expand Down
20 changes: 20 additions & 0 deletions app/src/main/java/app/waveflow/data/PreferencesStore.kt
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import app.waveflow.model.AppPreferences
import app.waveflow.model.PlaybackSpeed
import app.waveflow.model.StreamQuality
import app.waveflow.model.ThemeChoice
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
Expand Down Expand Up @@ -45,6 +46,14 @@ interface PreferencesStore {
* notification part à la bonne vitesse.
*/
suspend fun setPlaybackSpeed(speed: Float)

/**
* Change la qualité de lecture des pistes du serveur.
*
* Comme la vitesse, c'est le service qui l'observe : une file lancée depuis
* Android Auto doit partir dans la qualité choisie, écran fermé.
*/
suspend fun setStreamQuality(quality: StreamQuality)
}

/**
Expand Down Expand Up @@ -114,6 +123,10 @@ class DataStorePreferencesStore(
override suspend fun setPlaybackSpeed(speed: Float) =
ecrire("Vitesse de lecture") { it[PLAYBACK_SPEED] = PlaybackSpeed.borner(speed) }

/** Écrite par son nom, comme le thème : voir [StreamQuality] pour ce que cela interdit. */
override suspend fun setStreamQuality(quality: StreamQuality) =
ecrire("Qualité de lecture") { it[STREAM_QUALITY] = quality.name }

/**
* Écrit une préférence sans faire tomber celui qui la demande.
*
Expand Down Expand Up @@ -167,6 +180,12 @@ class DataStorePreferencesStore(
playbackSpeed = (valeurs[PLAYBACK_SPEED] as? Float)
?.let(PlaybackSpeed::borner)
?: AppPreferences().playbackSpeed,
// Un profil inconnu retombe sur l'original, jamais sur un autre
// profil transcodé : ne pas savoir ce qu'on a choisi ne doit pas
// décider à la place de l'utilisateur de ce qu'il perd en fidélité.
streamQuality = (valeurs[STREAM_QUALITY] as? String)
?.let { name -> StreamQuality.entries.firstOrNull { it.name == name } }
?: AppPreferences().streamQuality,
)
}

Expand All @@ -175,6 +194,7 @@ class DataStorePreferencesStore(

val THEME = stringPreferencesKey("theme")
val PLAYBACK_SPEED = floatPreferencesKey("playback_speed")
val STREAM_QUALITY = stringPreferencesKey("stream_quality")

/** Trois reprises : de quoi passer un incident, pas une corruption. */
const val MAX_TENTATIVES = 3L
Expand Down
20 changes: 19 additions & 1 deletion app/src/main/java/app/waveflow/data/remote/CatalogApi.kt
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import app.waveflow.model.RemoteAlbumDetail
import app.waveflow.model.RemoteArtist
import app.waveflow.model.RemoteArtistDetail
import app.waveflow.model.RemoteSearchResults
import app.waveflow.model.StreamRendering

/**
* Lecture du catalogue d'un serveur WaveFlow.
Expand Down Expand Up @@ -57,8 +58,25 @@ interface CatalogApi {
* 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.
*
* Le ticket ne porte pas le rendu : [rendering] s'ajoute à l'URL rendue, et
* le serveur le lit au moment de servir. L'original n'y ajoute rien — le
* serveur refuse un débit accompagnant `raw`.
*/
suspend fun streamTicket(
serverUrl: String,
accessToken: String,
trackId: String,
rendering: StreamRendering,
): String

/**
* `GET /api/v2/transcode/status` : le serveur sait-il transcoder ?
*
* Un serveur sans ffmpeg sert les originaux et rien d'autre. Lui demander
* une version transcodée ferait échouer chaque piste.
*/
suspend fun streamTicket(serverUrl: String, accessToken: String, trackId: String): String
suspend fun transcodingAvailable(serverUrl: String, accessToken: String): Boolean
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import app.waveflow.model.RemoteAlbumDetail
import app.waveflow.model.RemoteArtist
import app.waveflow.model.RemoteArtistDetail
import app.waveflow.model.RemoteSearchResults
import app.waveflow.model.StreamRendering

/**
* Le catalogue distant, muni d'une session.
Expand Down Expand Up @@ -39,8 +40,12 @@ class CatalogRepository(
* 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) }
suspend fun streamUrl(trackId: String, rendering: StreamRendering): String =
authorized { url, token -> api.streamTicket(url, token, trackId, rendering) }

/** Voir [CatalogApi.transcodingAvailable]. */
suspend fun transcodingAvailable(): Boolean =
authorized { url, token -> api.transcodingAvailable(url, token) }

/**
* Exécute [call] avec un jeton valide, en réessayant une fois sur refus.
Expand Down
12 changes: 12 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 @@ -139,6 +139,18 @@ internal data class StreamTicketResponse(
@SerialName("expires_at") val expiresAt: Long,
)

/**
* `GET /api/v2/transcode/status`.
*
* Le serveur rend aussi `active`, `global_limit` et `per_user_limit` ; seul
* `available` sert aujourd'hui, et un champ lu sans être utilisé serait un
* champ de plus à croire à jour.
*/
@Serializable
internal data class TranscodeStatusResponse(
val available: Boolean,
)

@Serializable
internal data class ArtistDetailResponse(
val id: String,
Expand Down
23 changes: 22 additions & 1 deletion app/src/main/java/app/waveflow/data/remote/HttpCatalogApi.kt
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import app.waveflow.model.RemoteAlbumDetail
import app.waveflow.model.RemoteArtist
import app.waveflow.model.RemoteArtistDetail
import app.waveflow.model.RemoteSearchResults
import app.waveflow.model.StreamRendering
import kotlinx.serialization.SerializationException

/** Catalogue distant, par-dessus [ServerHttp]. */
Expand Down Expand Up @@ -96,6 +97,7 @@ class HttpCatalogApi(
serverUrl: String,
accessToken: String,
trackId: String,
rendering: StreamRendering,
): String {
val ticket = http.post(
serverUrl = serverUrl,
Expand All @@ -104,9 +106,27 @@ class HttpCatalogApi(
accessToken = accessToken,
).decode<StreamTicketResponse>()

return http.absoluteUrl(serverUrl, ticket.url)
return http.absoluteUrl(serverUrl, ticket.url, query = rendering.toQuery())
}

override suspend fun transcodingAvailable(serverUrl: String, accessToken: String): Boolean =
http.get(
serverUrl = serverUrl,
path = TRANSCODE_STATUS,
accessToken = accessToken,
).decode<TranscodeStatusResponse>().available

/** L'original ne dit rien : le serveur le sert par défaut, et refuse qu'on lui donne un débit. */
private fun StreamRendering.toQuery(): Map<String, String> =
if (isOriginal) {
emptyMap()
} else {
buildMap {
put("format", format)
bitrate?.let { put("bitrate", it.toString()) }
}
}

private fun artwork(serverUrl: String) = ArtworkUrls(serverUrl, http)

private fun pageQuery(offset: Int, limit: Int) = mapOf(
Expand All @@ -121,6 +141,7 @@ class HttpCatalogApi(
}

private companion object {
const val TRANSCODE_STATUS = "api/v2/transcode/status"
const val ALBUMS = "api/v2/albums"
const val ARTISTS = "api/v2/artists"
const val TRACKS = "api/v2/tracks"
Expand Down
10 changes: 8 additions & 2 deletions app/src/main/java/app/waveflow/data/remote/ServerHttp.kt
Original file line number Diff line number Diff line change
Expand Up @@ -144,14 +144,20 @@ class ServerHttp(
*
* @param pathSegment ajouté après [path], et encodé — un identifiant ou un
* hachage venu d'une réponse n'a pas à être interpolé dans le chemin.
* @param query ajoutée encodée, pour la même raison.
*/
fun absoluteUrl(serverUrl: String, path: String, pathSegment: String? = null): String {
fun absoluteUrl(
serverUrl: String,
path: String,
pathSegment: String? = null,
query: Map<String, String> = emptyMap(),
): String {
if (!path.startsWith("/") || path.startsWith("//")) {
throw ServerException.Unexpected("Chemin de diffusion inattendu : $path")
}

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

Expand Down
3 changes: 3 additions & 0 deletions app/src/main/java/app/waveflow/model/AppPreferences.kt
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,13 @@ package app.waveflow.model
* @property playbackSpeed la vitesse de lecture. Elle se persiste parce qu'elle
* décrit une habitude et non un geste : qui écoute ses podcasts à ×1,5 ne
* veut pas le redire à chaque lancement. Voir [PlaybackSpeed] pour ses bornes.
* @property streamQuality ce qu'on demande au serveur pour chaque piste. Elle
* ne dit rien des fichiers de l'appareil, qui ne passent pas par lui.
*/
data class AppPreferences(
val theme: ThemeChoice = ThemeChoice.System,
val playbackSpeed: Float = PlaybackSpeed.NORMALE,
val streamQuality: StreamQuality = StreamQuality.Original,
)

/**
Expand Down
62 changes: 62 additions & 0 deletions app/src/main/java/app/waveflow/model/StreamQuality.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
package app.waveflow.model

/**
* Ce qu'on demande au serveur pour une piste : le fichier tel quel, ou une
* version transcodée à un débit donné.
*
* @property format `raw`, `mp3` ou `opus` — les trois que le serveur sait servir.
* @property bitrate en kbit/s. `null` pour l'original, qui n'a pas de débit à
* choisir : le serveur refuse d'ailleurs un débit accompagnant `raw`.
*/
data class StreamRendering(val format: String, val bitrate: Int?) {

/** Le fichier tel qu'il est sur le serveur, sans transcodage. */
val isOriginal: Boolean get() = format == FORMAT_ORIGINAL

companion object {
/** Le défaut du serveur : aucun transcodage. */
const val FORMAT_ORIGINAL = "raw"

val ORIGINAL = StreamRendering(FORMAT_ORIGINAL, bitrate = null)
}
}

/**
* La qualité de lecture des pistes du serveur.
*
* Des profils et non un codec et un débit à régler : on sait ce qu'on veut
* épargner — un forfait, une liaison lente —, rarement ce que valent 96 kbit/s
* d'Opus face à 192 de MP3. Le résumé de chaque profil parle donc de ce qu'il
* coûte, pas de la manière dont il est obtenu.
*
* Opus pour les deux profils transcodés : à débit égal, c'est le plus fidèle des
* deux formats que le serveur sait produire, et Android le décode de lui-même
* sur toutes les versions que l'application accepte.
*
* [Original] est le défaut. Ne toucher à rien ne doit rien changer à ce qu'on
* entendait avant que le réglage n'existe.
*
* **Le nom de chaque entrée est ce qui s'écrit sur le disque.** En renommer une
* ferait retomber sur le défaut quiconque l'avait choisie.
*/
enum class StreamQuality(
val label: String,
val summary: String,
val rendering: StreamRendering,
) {
Original(
label = "Qualité d'origine",
summary = "Le fichier tel qu'il est sur le serveur. Le plus fidèle, et le plus lourd.",
rendering = StreamRendering.ORIGINAL,
),
Haute(
label = "Haute qualité",
summary = "Environ 70 Mo par heure d'écoute, très proche de l'original.",
rendering = StreamRendering(format = "opus", bitrate = 160),
),
Economie(
label = "Économie",
summary = "Environ 45 Mo par heure d'écoute, pour un forfait serré.",
rendering = StreamRendering(format = "opus", bitrate = 96),
),
}
Loading