Add album page, Media3 playback and mini player

- Tapping an album opens its page (cover, details, track list in disc/track
  order); tapping a track plays it with the rest of the album queued after.
- PlaybackService (Media3 MediaLibraryService + ExoPlayer) plays in the
  background and with the screen off, with notification and lock-screen
  controls, audio focus, and pause on headphone unplug. Exported with the
  MediaLibraryService/MediaBrowserService filters for Android Auto later;
  library browsing returns an empty root until then.
- Streams and notification artwork load through the authenticated OkHttp
  client, so credentials never appear in URLs. The session attaches stream
  URLs from media ids.
- Mini player (artwork, title, play/pause, next) above the navigation bar
  on every screen; the playing track is highlighted on the album page.
- Signing out stops playback and clears the queue.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
TySmi 2026-10-11 19:08:03 -04:00
parent 99b57f0162
commit 560ef40624
24 changed files with 956 additions and 14 deletions

6
.idea/vcs.xml Normal file
View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" />
</component>
</project>

View file

@ -57,6 +57,10 @@ dependencies {
implementation(libs.kotlinx.serialization.json)
implementation(libs.coil.compose)
implementation(libs.coil.network.okhttp)
implementation(libs.media3.exoplayer)
implementation(libs.media3.datasource.okhttp)
implementation(libs.media3.session)
implementation(libs.kotlinx.coroutines.guava)
testImplementation(libs.junit)
testImplementation(libs.kotlinx.coroutines.test)

View file

@ -3,6 +3,10 @@
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" />
<!-- Background playback: run as a media foreground service and keep CPU/Wi-Fi awake. -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<application
android:name=".SyncWaveApplication"
@ -27,6 +31,17 @@
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<!-- Exported so system media controls, and later Android Auto, can connect. -->
<service
android:name=".playback.PlaybackService"
android:exported="true"
android:foregroundServiceType="mediaPlayback">
<intent-filter>
<action android:name="androidx.media3.session.MediaLibraryService" />
<action android:name="android.media.browse.MediaBrowserService" />
</intent-filter>
</service>
</application>
</manifest>

View file

@ -6,6 +6,7 @@ import com.tysmi.syncwave.data.auth.SessionManager
import com.tysmi.syncwave.data.library.LibraryRepository
import com.tysmi.syncwave.data.subsonic.SubsonicApiFactory
import com.tysmi.syncwave.data.subsonic.SubsonicAuthInterceptor
import com.tysmi.syncwave.playback.PlaybackController
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
@ -44,4 +45,6 @@ class AppContainer(context: Context) {
.build()
val libraryRepository = LibraryRepository(sessionManager)
val playbackController = PlaybackController(context, libraryRepository, appScope)
}

View file

@ -8,14 +8,27 @@ import com.tysmi.syncwave.ui.navigation.SyncWaveRoot
import com.tysmi.syncwave.ui.theme.SyncWaveTheme
class MainActivity : ComponentActivity() {
private val container get() = (application as SyncWaveApplication).container
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
val container = (application as SyncWaveApplication).container
setContent {
SyncWaveTheme {
SyncWaveRoot(container.sessionManager)
SyncWaveRoot(container.sessionManager, container.playbackController)
}
}
}
// Talk to the playback service only while visible; music keeps playing regardless.
override fun onStart() {
super.onStart()
container.playbackController.connect()
}
override fun onStop() {
container.playbackController.disconnect()
super.onStop()
}
}

View file

@ -2,9 +2,14 @@ package com.tysmi.syncwave.data.library
import com.tysmi.syncwave.data.auth.SessionManager
import com.tysmi.syncwave.data.subsonic.AlbumDto
import com.tysmi.syncwave.data.subsonic.AlbumWithSongsDto
import com.tysmi.syncwave.data.subsonic.SongDto
import com.tysmi.syncwave.data.subsonic.SubsonicException
import com.tysmi.syncwave.data.subsonic.SubsonicUrls
import com.tysmi.syncwave.data.subsonic.orThrow
import com.tysmi.syncwave.model.Album
import com.tysmi.syncwave.model.AlbumDetail
import com.tysmi.syncwave.model.Track
/** Music library access for the rest of the app. Hides which API the data comes from. */
class LibraryRepository(
@ -18,8 +23,22 @@ class LibraryRepository(
.albumList2?.album.orEmpty()
.map { it.toAlbum() }
/** An album and its tracks, in disc/track order. */
suspend fun getAlbum(id: String): AlbumDetail {
val dto = session.requireApi().getAlbum(id).orThrow().album
?: throw SubsonicException(SubsonicException.NOT_FOUND, "Album not found")
val album = dto.toAlbum()
val tracks = dto.song
.map { it.toTrack(album) }
.sortedWith(compareBy({ it.discNumber ?: 1 }, { it.trackNumber ?: Int.MAX_VALUE }))
return AlbumDetail(album, tracks)
}
fun coverArtUrl(coverArtId: String, sizePx: Int): String? =
session.currentCredentials?.let { SubsonicUrls.coverArt(it, coverArtId, sizePx) }
fun streamUrl(trackId: String): String? =
session.currentCredentials?.let { SubsonicUrls.stream(it, trackId) }
}
private fun AlbumDto.toAlbum() = Album(
@ -30,3 +49,25 @@ private fun AlbumDto.toAlbum() = Album(
songCount = songCount,
coverArtId = coverArt,
)
private fun AlbumWithSongsDto.toAlbum() = Album(
id = id,
name = name,
artist = artist.orEmpty(),
year = year,
songCount = songCount,
coverArtId = coverArt,
)
private fun SongDto.toTrack(album: Album) = Track(
id = id,
title = title,
artist = artist ?: album.artist,
albumId = albumId ?: album.id,
albumName = this.album ?: album.name,
trackNumber = track,
discNumber = discNumber,
durationSeconds = duration,
// Prefer the album's art so every track shares one cached image.
coverArtId = album.coverArtId ?: coverArt,
)

View file

@ -21,6 +21,10 @@ interface SubsonicApi {
@Query("offset") offset: Int,
): SubsonicEnvelope
/** One album with its songs, in album order. */
@GET("rest/getAlbum")
suspend fun getAlbum(@Query("id") id: String): SubsonicEnvelope
companion object {
/** Largest page the Subsonic API allows for album lists. */
const val MAX_ALBUM_PAGE_SIZE = 500

View file

@ -25,8 +25,19 @@ class SubsonicApiFactory(
}
}
/** Builds URLs for media endpoints that are loaded outside Retrofit (images now, streams later). */
/** Builds URLs for media endpoints that are loaded outside Retrofit (images and audio). */
object SubsonicUrls {
/**
* Audio stream URL *without* credentials; the player's HTTP client is the authenticated one.
* The server sends the original file, so seeking works via HTTP range requests.
*/
fun stream(credentials: ServerCredentials, songId: String): String =
credentials.baseUrl.newBuilder()
.addPathSegments("rest/stream")
.addQueryParameter("id", songId)
.build()
.toString()
/**
* Cover art URL *without* credentials; the auth interceptor on the image loader's HTTP
* client adds them. Keeping them out of the URL means the image cache key stays stable.

View file

@ -21,6 +21,7 @@ data class SubsonicResponse(
val openSubsonic: Boolean = false,
val error: SubsonicErrorDto? = null,
val albumList2: AlbumList2Dto? = null,
val album: AlbumWithSongsDto? = null,
)
@Serializable
@ -45,6 +46,32 @@ data class AlbumDto(
val year: Int? = null,
)
@Serializable
data class AlbumWithSongsDto(
val id: String,
val name: String = "",
val artist: String? = null,
val artistId: String? = null,
val coverArt: String? = null,
val songCount: Int = 0,
val year: Int? = null,
val song: List<SongDto> = emptyList(),
)
@Serializable
data class SongDto(
val id: String,
val title: String = "",
val artist: String? = null,
val album: String? = null,
val albumId: String? = null,
val track: Int? = null,
val discNumber: Int? = null,
/** Seconds. */
val duration: Int? = null,
val coverArt: String? = null,
)
/** The server answered, but with `status: "failed"`. See the Subsonic API error codes. */
class SubsonicException(val code: Int, message: String?) : Exception(message ?: "Subsonic error $code") {
companion object {

View file

@ -0,0 +1,19 @@
package com.tysmi.syncwave.model
/** A playable song. Independent of the OpenSubsonic wire format, like [Album]. */
data class Track(
val id: String,
val title: String,
val artist: String,
val albumId: String?,
val albumName: String,
val trackNumber: Int?,
val discNumber: Int?,
val durationSeconds: Int?,
val coverArtId: String?,
)
data class AlbumDetail(
val album: Album,
val tracks: List<Track>,
)

View file

@ -0,0 +1,152 @@
package com.tysmi.syncwave.playback
import android.content.ComponentName
import android.content.Context
import android.util.Log
import androidx.annotation.OptIn
import androidx.core.content.ContextCompat
import androidx.core.net.toUri
import androidx.media3.common.MediaItem
import androidx.media3.common.MediaMetadata
import androidx.media3.common.Player
import androidx.media3.common.util.UnstableApi
import androidx.media3.common.util.Util
import androidx.media3.session.MediaController
import androidx.media3.session.SessionToken
import com.google.common.util.concurrent.ListenableFuture
import com.tysmi.syncwave.data.library.LibraryRepository
import com.tysmi.syncwave.model.Track
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.guava.await
import kotlinx.coroutines.launch
data class NowPlaying(
val trackId: String,
val title: String,
val artist: String,
val artworkUrl: String?,
)
data class PlayerUiState(
val nowPlaying: NowPlaying? = null,
val isPlaying: Boolean = false,
val hasNext: Boolean = false,
)
/**
* The UI's remote control for [PlaybackService]. Connects while the app is on screen
* ([connect]/[disconnect] from the Activity); the service keeps playing either way.
*/
@OptIn(UnstableApi::class)
class PlaybackController(
private val context: Context,
private val library: LibraryRepository,
private val scope: CoroutineScope,
) {
private val _state = MutableStateFlow(PlayerUiState())
val state: StateFlow<PlayerUiState> = _state.asStateFlow()
private var controllerFuture: ListenableFuture<MediaController>? = null
private var controller: MediaController? = null
private val listener = object : Player.Listener {
override fun onEvents(player: Player, events: Player.Events) = publish(player)
}
fun connect() {
if (controllerFuture != null) return
val token = SessionToken(context, ComponentName(context, PlaybackService::class.java))
val future = MediaController.Builder(context, token).buildAsync()
controllerFuture = future
future.addListener({
if (controllerFuture !== future) return@addListener // disconnected meanwhile
try {
controller = future.get().also {
it.addListener(listener)
publish(it)
}
} catch (e: Exception) {
Log.e(TAG, "Couldn't connect to playback service", e)
controllerFuture = null
}
}, ContextCompat.getMainExecutor(context))
}
fun disconnect() {
controller?.removeListener(listener)
controllerFuture?.let(MediaController::releaseFuture)
controllerFuture = null
controller = null
}
/** Replaces the queue with [tracks] and starts playing at [startIndex]. */
fun play(tracks: List<Track>, startIndex: Int) {
scope.launch {
try {
connect()
val player = checkNotNull(controllerFuture).await()
player.setMediaItems(tracks.map { it.toMediaItem() }, startIndex, 0L)
player.prepare()
player.play()
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Log.e(TAG, "Couldn't start playback", e)
}
}
}
fun togglePlayPause() {
controller?.let { Util.handlePlayPauseButtonAction(it) }
}
fun skipToNext() {
controller?.seekToNextMediaItem()
}
private fun publish(player: Player) {
val item = player.currentMediaItem
_state.value = PlayerUiState(
nowPlaying = item?.let {
NowPlaying(
trackId = it.mediaId,
title = it.mediaMetadata.title?.toString().orEmpty(),
artist = it.mediaMetadata.artist?.toString().orEmpty(),
artworkUrl = it.mediaMetadata.artworkUri?.toString(),
)
},
isPlaying = !Util.shouldShowPlayButton(player),
hasNext = player.hasNextMediaItem(),
)
}
private fun Track.toMediaItem(): MediaItem {
val artwork = coverArtId?.let { library.coverArtUrl(it, ARTWORK_SIZE_PX) }
return MediaItem.Builder()
.setMediaId(id) // the service turns this into a stream URL
.setMediaMetadata(
MediaMetadata.Builder()
.setTitle(title)
.setArtist(artist)
.setAlbumTitle(albumName)
.setTrackNumber(trackNumber)
.setDiscNumber(discNumber)
.setDurationMs(durationSeconds?.let { it * 1000L })
.setArtworkUri(artwork?.toUri())
.setIsPlayable(true)
.setIsBrowsable(false)
.setMediaType(MediaMetadata.MEDIA_TYPE_MUSIC)
.build(),
)
.build()
}
private companion object {
const val TAG = "PlaybackController"
const val ARTWORK_SIZE_PX = 400 // same size as the grid, so it's already cached
}
}

View file

@ -0,0 +1,155 @@
package com.tysmi.syncwave.playback
import android.app.PendingIntent
import android.content.Intent
import androidx.annotation.OptIn
import androidx.media3.common.AudioAttributes
import androidx.media3.common.C
import androidx.media3.common.MediaItem
import androidx.media3.common.MediaMetadata
import androidx.media3.common.util.UnstableApi
import androidx.media3.datasource.DataSourceBitmapLoader
import androidx.media3.datasource.okhttp.OkHttpDataSource
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
import androidx.media3.session.CacheBitmapLoader
import androidx.media3.session.LibraryResult
import androidx.media3.session.MediaLibraryService
import androidx.media3.session.MediaSession
import com.google.common.collect.ImmutableList
import com.google.common.util.concurrent.Futures
import com.google.common.util.concurrent.ListenableFuture
import com.tysmi.syncwave.MainActivity
import com.tysmi.syncwave.SyncWaveApplication
import com.tysmi.syncwave.data.auth.SessionState
import com.tysmi.syncwave.data.library.LibraryRepository
import kotlinx.coroutines.MainScope
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
/**
* Owns the player. Runs as a foreground service while music plays, so playback continues in
* the background and with the screen off. Media3 posts the notification and lock-screen
* controls from this session. The app's screens (and later Android Auto) control it through a
* MediaController; see [PlaybackController].
*/
@OptIn(UnstableApi::class)
class PlaybackService : MediaLibraryService() {
private var session: MediaLibrarySession? = null
private val scope = MainScope()
override fun onCreate() {
super.onCreate()
val container = (application as SyncWaveApplication).container
// Audio and notification artwork both load through the client that signs requests
// to the logged-in server, so no credentials are ever put in a URL.
val httpDataSource = OkHttpDataSource.Factory(container.authenticatedHttpClient)
val player = ExoPlayer.Builder(this)
.setMediaSourceFactory(DefaultMediaSourceFactory(this).setDataSourceFactory(httpDataSource))
.setAudioAttributes(
AudioAttributes.Builder()
.setUsage(C.USAGE_MEDIA)
.setContentType(C.AUDIO_CONTENT_TYPE_MUSIC)
.build(),
/* handleAudioFocus = */ true,
)
.setHandleAudioBecomingNoisy(true) // pause when headphones are unplugged
.setWakeMode(C.WAKE_MODE_NETWORK) // keep CPU and Wi-Fi awake while streaming
.build()
val openApp = PendingIntent.getActivity(
this,
0,
Intent(this, MainActivity::class.java),
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
)
session = MediaLibrarySession.Builder(this, player, LibraryCallback(container.libraryRepository))
.setSessionActivity(openApp)
.setBitmapLoader(
CacheBitmapLoader(DataSourceBitmapLoader.Builder(this).setDataSourceFactory(httpDataSource).build()),
)
.build()
// Signing out stops the music and forgets the queue.
scope.launch {
container.sessionManager.state.collect { state ->
if (state is SessionState.LoggedOut) {
player.stop()
player.clearMediaItems()
}
}
}
}
override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaLibrarySession? = session
override fun onDestroy() {
scope.cancel()
session?.run {
player.release()
release()
}
session = null
super.onDestroy()
}
}
/**
* Answers controllers. Items arrive by media id (track id); we attach the stream URL here so
* the URL is always built for the current server. Browsing (for Android Auto) returns an empty
* library for now and gets filled in when Auto support is added.
*/
@OptIn(UnstableApi::class)
private class LibraryCallback(
private val library: LibraryRepository,
) : MediaLibraryService.MediaLibrarySession.Callback {
private val root = MediaItem.Builder()
.setMediaId(ROOT_ID)
.setMediaMetadata(
MediaMetadata.Builder()
.setTitle("SyncWave")
.setIsBrowsable(true)
.setIsPlayable(false)
.setMediaType(MediaMetadata.MEDIA_TYPE_FOLDER_MIXED)
.build(),
)
.build()
override fun onGetLibraryRoot(
session: MediaLibraryService.MediaLibrarySession,
browser: MediaSession.ControllerInfo,
params: MediaLibraryService.LibraryParams?,
): ListenableFuture<LibraryResult<MediaItem>> =
Futures.immediateFuture(LibraryResult.ofItem(root, params))
override fun onGetChildren(
session: MediaLibraryService.MediaLibrarySession,
browser: MediaSession.ControllerInfo,
parentId: String,
page: Int,
pageSize: Int,
params: MediaLibraryService.LibraryParams?,
): ListenableFuture<LibraryResult<ImmutableList<MediaItem>>> =
Futures.immediateFuture(LibraryResult.ofItemList(ImmutableList.of(), params))
// setMediaItems() from a controller also lands here by default.
override fun onAddMediaItems(
mediaSession: MediaSession,
controller: MediaSession.ControllerInfo,
mediaItems: MutableList<MediaItem>,
): ListenableFuture<MutableList<MediaItem>> =
Futures.immediateFuture(
mediaItems.mapNotNull { item ->
library.streamUrl(item.mediaId)?.let { url -> item.buildUpon().setUri(url).build() }
}.toMutableList(),
)
private companion object {
const val ROOT_ID = "root"
}
}

View file

@ -0,0 +1,190 @@
package com.tysmi.syncwave.ui.album
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
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.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import coil3.compose.AsyncImage
import com.tysmi.syncwave.R
import com.tysmi.syncwave.model.AlbumDetail
import com.tysmi.syncwave.model.Track
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AlbumScreen(
onBack: () -> Unit,
viewModel: AlbumViewModel = viewModel(factory = AlbumViewModel.Factory),
) {
val state by viewModel.state.collectAsStateWithLifecycle()
Scaffold(
topBar = {
TopAppBar(
title = { Text(state.title, maxLines = 1, overflow = TextOverflow.Ellipsis) },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(painterResource(R.drawable.ic_arrow_back), contentDescription = "Back")
}
},
)
},
) { innerPadding ->
Box(Modifier.fillMaxSize().padding(innerPadding)) {
val detail = state.detail
when {
detail != null -> TrackList(
detail = detail,
coverArtUrl = state.coverArtUrl,
playingTrackId = state.playingTrackId,
onTrackClick = viewModel::playTrack,
)
state.error != null -> Column(
Modifier.fillMaxSize().padding(32.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(state.error!!, textAlign = TextAlign.Center)
Button(onClick = viewModel::retry, modifier = Modifier.padding(top = 16.dp)) { Text("Retry") }
}
else -> CircularProgressIndicator(Modifier.align(Alignment.Center))
}
}
}
}
@Composable
private fun TrackList(
detail: AlbumDetail,
coverArtUrl: String?,
playingTrackId: String?,
onTrackClick: (Int) -> Unit,
) {
LazyColumn(Modifier.fillMaxSize()) {
item { AlbumHeader(detail, coverArtUrl) }
itemsIndexed(detail.tracks, key = { _, track -> track.id }) { index, track ->
TrackRow(
track = track,
albumArtist = detail.album.artist,
isPlaying = track.id == playingTrackId,
onClick = { onTrackClick(index) },
)
}
}
}
@Composable
private fun AlbumHeader(detail: AlbumDetail, coverArtUrl: String?) {
val album = detail.album
Column(
Modifier.fillMaxWidth().padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
AsyncImage(
model = coverArtUrl,
contentDescription = "Cover of ${album.name}",
contentScale = ContentScale.Crop,
modifier = Modifier
.size(240.dp)
.clip(RoundedCornerShape(8.dp))
.background(MaterialTheme.colorScheme.surfaceVariant),
)
Text(
album.name,
style = MaterialTheme.typography.headlineSmall,
textAlign = TextAlign.Center,
modifier = Modifier.padding(top = 16.dp),
)
Text(album.artist, style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary)
val songs = detail.tracks.size
Text(
listOfNotNull(album.year?.toString(), "$songs ${if (songs == 1) "song" else "songs"}").joinToString(" · "),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
@Composable
private fun TrackRow(track: Track, albumArtist: String, isPlaying: Boolean, onClick: () -> Unit) {
val highlight = if (isPlaying) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
track.trackNumber?.toString().orEmpty(),
style = MaterialTheme.typography.bodyMedium,
color = if (isPlaying) highlight else MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.End,
modifier = Modifier.width(28.dp),
)
Column(Modifier.weight(1f).padding(horizontal = 16.dp)) {
Text(
track.title,
style = MaterialTheme.typography.bodyLarge,
fontWeight = if (isPlaying) FontWeight.SemiBold else null,
color = highlight,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
// Only show the artist when it differs, e.g. guest artists on compilations.
if (track.artist != albumArtist) {
Text(
track.artist,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
Text(
track.durationSeconds?.let(::formatDuration).orEmpty(),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
/** 312 → "5:12", 3725 → "1:02:05". */
internal fun formatDuration(totalSeconds: Int): String {
val hours = totalSeconds / 3600
val minutes = (totalSeconds % 3600) / 60
val seconds = totalSeconds % 60
return if (hours > 0) "%d:%02d:%02d".format(hours, minutes, seconds) else "%d:%02d".format(minutes, seconds)
}

View file

@ -0,0 +1,93 @@
package com.tysmi.syncwave.ui.album
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.createSavedStateHandle
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import androidx.navigation.toRoute
import com.tysmi.syncwave.data.library.LibraryRepository
import com.tysmi.syncwave.model.AlbumDetail
import com.tysmi.syncwave.playback.PlaybackController
import com.tysmi.syncwave.ui.common.appContainer
import com.tysmi.syncwave.ui.common.toUserMessage
import com.tysmi.syncwave.ui.navigation.AlbumRoute
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
data class AlbumUiState(
/** Shown in the top bar right away, before the album loads. */
val title: String,
val detail: AlbumDetail? = null,
val coverArtUrl: String? = null,
val isLoading: Boolean = true,
val error: String? = null,
/** Track currently loaded in the player, to highlight it. */
val playingTrackId: String? = null,
)
class AlbumViewModel(
savedStateHandle: SavedStateHandle,
private val library: LibraryRepository,
private val playback: PlaybackController,
) : ViewModel() {
private val route = savedStateHandle.toRoute<AlbumRoute>()
private val loaded = MutableStateFlow(AlbumUiState(title = route.name))
val state: StateFlow<AlbumUiState> =
combine(loaded, playback.state) { album, player ->
album.copy(playingTrackId = player.nowPlaying?.trackId)
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), loaded.value)
init {
load()
}
fun retry() = load()
fun playTrack(index: Int) {
val tracks = loaded.value.detail?.tracks ?: return
playback.play(tracks, index)
}
private fun load() {
loaded.update { it.copy(isLoading = true, error = null) }
viewModelScope.launch {
try {
val detail = library.getAlbum(route.id)
loaded.update {
it.copy(
title = detail.album.name,
detail = detail,
coverArtUrl = detail.album.coverArtId?.let { id -> library.coverArtUrl(id, COVER_SIZE_PX) },
isLoading = false,
)
}
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
loaded.update { it.copy(isLoading = false, error = e.toUserMessage()) }
}
}
}
companion object {
// Same size as the grid, so the cover appears instantly from cache.
private const val COVER_SIZE_PX = 400
val Factory = viewModelFactory {
initializer {
val container = appContainer()
AlbumViewModel(createSavedStateHandle(), container.libraryRepository, container.playbackController)
}
}
}
}

View file

@ -1,6 +1,7 @@
package com.tysmi.syncwave.ui.library
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@ -39,10 +40,14 @@ import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import coil3.compose.AsyncImage
import com.tysmi.syncwave.model.Album
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AlbumGridScreen(viewModel: AlbumGridViewModel = viewModel(factory = AlbumGridViewModel.Factory)) {
fun AlbumGridScreen(
onAlbumClick: (Album) -> Unit,
viewModel: AlbumGridViewModel = viewModel(factory = AlbumGridViewModel.Factory),
) {
val state by viewModel.state.collectAsStateWithLifecycle()
Scaffold(
@ -65,14 +70,19 @@ fun AlbumGridScreen(viewModel: AlbumGridViewModel = viewModel(factory = AlbumGri
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { CircularProgressIndicator() }
state.items.isEmpty() && state.endReached ->
CenteredMessage("No albums found on this server.")
else -> AlbumGrid(state, onLoadMore = viewModel::loadMore, onRetry = viewModel::retry)
else -> AlbumGrid(state, onAlbumClick, onLoadMore = viewModel::loadMore, onRetry = viewModel::retry)
}
}
}
}
@Composable
private fun AlbumGrid(state: AlbumGridUiState, onLoadMore: () -> Unit, onRetry: () -> Unit) {
private fun AlbumGrid(
state: AlbumGridUiState,
onAlbumClick: (Album) -> Unit,
onLoadMore: () -> Unit,
onRetry: () -> Unit,
) {
val gridState = rememberLazyGridState()
// Ask for the next page when the user scrolls within ~2 rows of the end.
@ -94,7 +104,7 @@ private fun AlbumGrid(state: AlbumGridUiState, onLoadMore: () -> Unit, onRetry:
verticalArrangement = Arrangement.spacedBy(20.dp),
modifier = Modifier.fillMaxSize(),
) {
items(state.items, key = { it.album.id }) { item -> AlbumCard(item) }
items(state.items, key = { it.album.id }) { item -> AlbumCard(item, onClick = { onAlbumClick(item.album) }) }
if (state.isLoading || state.error != null) {
item(span = { GridItemSpan(maxLineSpan) }) {
@ -111,8 +121,8 @@ private fun AlbumGrid(state: AlbumGridUiState, onLoadMore: () -> Unit, onRetry:
}
@Composable
private fun AlbumCard(item: AlbumGridItem) {
Column {
private fun AlbumCard(item: AlbumGridItem, onClick: () -> Unit) {
Column(Modifier.clip(RoundedCornerShape(8.dp)).clickable(onClick = onClick)) {
AsyncImage(
model = item.coverArtUrl,
contentDescription = "Cover of ${item.album.name}",

View file

@ -1,7 +1,11 @@
package com.tysmi.syncwave.ui.navigation
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
@ -14,20 +18,28 @@ import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import com.tysmi.syncwave.data.auth.SessionManager
import com.tysmi.syncwave.data.auth.SessionState
import com.tysmi.syncwave.playback.PlaybackController
import com.tysmi.syncwave.ui.album.AlbumScreen
import com.tysmi.syncwave.ui.library.AlbumGridScreen
import com.tysmi.syncwave.ui.login.LoginScreen
import com.tysmi.syncwave.ui.player.MiniPlayer
import kotlinx.serialization.Serializable
/** Screens inside the logged-in app. Add album detail, now playing, etc. here. */
// Screens inside the logged-in app. Add now playing, artists, search, etc. here.
@Serializable
data object AlbumsRoute
/** [name] is shown in the top bar while the album loads. */
@Serializable
data class AlbumRoute(val id: String, val name: String)
/**
* Top of the UI: shows login when logged out and the main app when logged in. Logging out
* from anywhere drops back to login automatically.
*/
@Composable
fun SyncWaveRoot(sessionManager: SessionManager) {
fun SyncWaveRoot(sessionManager: SessionManager, playback: PlaybackController) {
val session by sessionManager.state.collectAsStateWithLifecycle()
when (val s = session) {
@ -35,7 +47,27 @@ fun SyncWaveRoot(sessionManager: SessionManager) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { CircularProgressIndicator() }
SessionState.LoggedOut -> LoginScreen()
// Keyed on the account so switching servers starts with fresh screens and data.
is SessionState.LoggedIn -> key(s.credentials) { MainNavHost() }
is SessionState.LoggedIn -> key(s.credentials) { MainScreen(playback) }
}
}
@Composable
private fun MainScreen(playback: PlaybackController) {
val player by playback.state.collectAsStateWithLifecycle()
val showMiniPlayer = player.nowPlaying != null
Column(Modifier.fillMaxSize()) {
// When the mini player is showing it sits above the system navigation bar, so the
// screens above it shouldn't leave room for that bar too.
val navHostModifier = Modifier.weight(1f).let {
if (showMiniPlayer) it.consumeWindowInsets(WindowInsets.navigationBars) else it
}
Box(navHostModifier) { MainNavHost() }
MiniPlayer(
state = player,
onPlayPause = playback::togglePlayPause,
onSkipNext = playback::skipToNext,
)
}
}
@ -43,6 +75,11 @@ fun SyncWaveRoot(sessionManager: SessionManager) {
private fun MainNavHost() {
val navController = rememberNavController()
NavHost(navController = navController, startDestination = AlbumsRoute) {
composable<AlbumsRoute> { AlbumGridScreen() }
composable<AlbumsRoute> {
AlbumGridScreen(onAlbumClick = { navController.navigate(AlbumRoute(it.id, it.name)) })
}
composable<AlbumRoute> {
AlbumScreen(onBack = { navController.popBackStack() })
}
}
}

View file

@ -0,0 +1,80 @@
package com.tysmi.syncwave.ui.player
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage
import com.tysmi.syncwave.R
import com.tysmi.syncwave.playback.NowPlaying
import com.tysmi.syncwave.playback.PlayerUiState
/** Bar at the bottom of every screen while something is loaded in the player. */
@Composable
fun MiniPlayer(
state: PlayerUiState,
onPlayPause: () -> Unit,
onSkipNext: () -> Unit,
modifier: Modifier = Modifier,
) {
val nowPlaying: NowPlaying = state.nowPlaying ?: return
Surface(tonalElevation = 3.dp, shadowElevation = 3.dp, modifier = modifier.fillMaxWidth()) {
Row(
modifier = Modifier
.navigationBarsPadding()
.padding(horizontal = 12.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
AsyncImage(
model = nowPlaying.artworkUrl,
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier
.size(48.dp)
.clip(RoundedCornerShape(6.dp))
.background(MaterialTheme.colorScheme.surfaceVariant),
)
Column(Modifier.weight(1f).padding(horizontal = 12.dp)) {
Text(
nowPlaying.title,
style = MaterialTheme.typography.titleSmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
nowPlaying.artist,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
IconButton(onClick = onPlayPause) {
Icon(
painterResource(if (state.isPlaying) R.drawable.ic_pause else R.drawable.ic_play),
contentDescription = if (state.isPlaying) "Pause" else "Play",
)
}
IconButton(onClick = onSkipNext, enabled = state.hasNext) {
Icon(painterResource(R.drawable.ic_skip_next), contentDescription = "Next track")
}
}
}
}

View file

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:autoMirrored="true">
<path
android:fillColor="@android:color/white"
android:pathData="M20,11H7.83l5.59,-5.59L12,4l-8,8 8,8 1.41,-1.41L7.83,13H20v-2z" />
</vector>

View file

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M6,19h4V5H6v14zM14,5v14h4V5h-4z" />
</vector>

View file

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M8,5v14l11,-7z" />
</vector>

View file

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M6,18l8.5,-6L6,6v12zM16,6v12h2V6h-2z" />
</vector>

View file

@ -106,6 +106,29 @@ class SubsonicApiTest {
assertNull(server.takeRequest().url.queryParameter("u"))
}
@Test
fun `album with songs parses`() = runTest {
respond(
"""{"subsonic-response":{"status":"ok","album":{"id":"a1","name":"Kid A","artist":"Radiohead",
"coverArt":"al-a1","song":[
{"id":"s1","title":"Everything in Its Right Place","track":1,"discNumber":1,"duration":251,"bitRate":320},
{"id":"s2","title":"Kid A","artist":"Radiohead","track":2}
]}}}""",
)
val album = api().getAlbum("a1").orThrow().album!!
assertEquals(listOf("s1", "s2"), album.song.map { it.id })
assertEquals(251, album.song[0].duration)
assertEquals("a1", server.takeRequest().url.queryParameter("id"))
}
@Test
fun `stream url has no credentials in it`() {
val url = SubsonicUrls.stream(ServerCredentials("https://music.example.com/nd", "ty", "sesame"), "s1")
assertEquals("https://music.example.com/nd/rest/stream?id=s1", url)
}
@Test
fun `cover art url has no credentials in it`() {
val url = SubsonicUrls.coverArt(ServerCredentials("https://music.example.com", "ty", "sesame"), "al-1", 400)

View file

@ -0,0 +1,13 @@
package com.tysmi.syncwave.ui.album
import org.junit.Assert.assertEquals
import org.junit.Test
class FormatDurationTest {
@Test
fun `formats minutes and hours`() {
assertEquals("0:07", formatDuration(7))
assertEquals("5:12", formatDuration(312))
assertEquals("1:02:05", formatDuration(3725))
}
}

View file

@ -12,6 +12,7 @@ okhttp = "5.4.0"
kotlinxSerialization = "1.10.0"
coil = "3.6.2"
coroutines = "1.10.2"
media3 = "1.11.1"
junit = "4.13.2"
junitVersion = "1.1.5"
espressoCore = "3.5.1"
@ -43,7 +44,11 @@ okhttp-mockwebserver = { group = "com.squareup.okhttp3", name = "mockwebserver3"
kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinxSerialization" }
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" }
kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "coroutines" }
media3-exoplayer = { group = "androidx.media3", name = "media3-exoplayer", version.ref = "media3" }
media3-datasource-okhttp = { group = "androidx.media3", name = "media3-datasource-okhttp", version.ref = "media3" }
media3-session = { group = "androidx.media3", name = "media3-session", version.ref = "media3" }
kotlinx-coroutines-guava = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-guava", version.ref = "coroutines" }
kotlinx-coroutines-test ={ group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "coroutines" }
junit = { group = "junit", name = "junit", version.ref = "junit" }
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }