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:
parent
99b57f0162
commit
560ef40624
24 changed files with 956 additions and 14 deletions
6
.idea/vcs.xml
Normal file
6
.idea/vcs.xml
Normal 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>
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
19
app/src/main/java/com/tysmi/syncwave/model/Track.kt
Normal file
19
app/src/main/java/com/tysmi/syncwave/model/Track.kt
Normal 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>,
|
||||
)
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
155
app/src/main/java/com/tysmi/syncwave/playback/PlaybackService.kt
Normal file
155
app/src/main/java/com/tysmi/syncwave/playback/PlaybackService.kt
Normal 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"
|
||||
}
|
||||
}
|
||||
190
app/src/main/java/com/tysmi/syncwave/ui/album/AlbumScreen.kt
Normal file
190
app/src/main/java/com/tysmi/syncwave/ui/album/AlbumScreen.kt
Normal 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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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}",
|
||||
|
|
|
|||
|
|
@ -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() })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
80
app/src/main/java/com/tysmi/syncwave/ui/player/MiniPlayer.kt
Normal file
80
app/src/main/java/com/tysmi/syncwave/ui/player/MiniPlayer.kt
Normal 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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
11
app/src/main/res/drawable/ic_arrow_back.xml
Normal file
11
app/src/main/res/drawable/ic_arrow_back.xml
Normal 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>
|
||||
10
app/src/main/res/drawable/ic_pause.xml
Normal file
10
app/src/main/res/drawable/ic_pause.xml
Normal 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>
|
||||
10
app/src/main/res/drawable/ic_play.xml
Normal file
10
app/src/main/res/drawable/ic_play.xml
Normal 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>
|
||||
10
app/src/main/res/drawable/ic_skip_next.xml
Normal file
10
app/src/main/res/drawable/ic_skip_next.xml
Normal 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>
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
|
@ -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" }
|
||||
|
|
|
|||
Loading…
Reference in a new issue