Phase 4: Media3 playback engine

Builds the single MediaSession-backed player that in-app playback,
offline downloads (Phase 5), and Android Auto (Phase 6) will all
share, per the Phase 2+ roadmap's Phase 4.

- DeepwavePlaybackService: ExoPlayer + MediaLibrarySession, built as
  a MediaLibraryService from day one (not the plainer
  MediaSessionService) so Android Auto only has to add browse-tree
  content later, never rebuild the service. Browse tree is a stub
  for now (MediaLibrarySession.Callback's defaults deny browsing).
- LocalOrRemoteDataSource: the entire "prefer a downloaded file over
  streaming" mechanism as a ResolvingDataSource.Resolver, backed by
  an in-memory LocalTrackFiles registry that stays empty until
  Phase 5 populates it from Room - reused unmodified by Android Auto
  later.
- ExoPlayer's OkHttpDataSource shares the same signed OkHttpClient as
  Retrofit/Coil, so SubsonicRequestInterceptor signs stream requests
  identically to every other Subsonic call - streamUrl() builds the
  request URL the same way coverArtUrl() does for Coil.
- PlaybackController: app-facing facade over a MediaController,
  exposing Flow<PlaybackUiState> - same facade convention as
  ServerRepository. NowPlayingScreen (new, full-screen: artwork,
  seek bar, play/pause/skip) and MiniPlayerBar (now real, replacing
  Phase 2/3's static placeholder) both consume it.
- Track clicks in AlbumDetailScreen (queues the whole album from the
  clicked index) and SearchScreen (single-song queue) now actually
  play, via PlaybackController injected into their ViewModels.
- Scrobbling: submission=false when a track starts, submission=true
  for the outgoing track on each transition - a simplified heuristic
  rather than a played-percentage threshold, feeding Navidrome's
  play-count data that Phase 3's getAlbumList2(frequent/recent) rows
  read from.
- Runtime POST_NOTIFICATIONS request added to MainActivity for API
  33+ (declared in the manifest but easy to forget the runtime half
  of - without it the service still plays, but its notification
  never shows).

Split MainScreen into a thin PlayerViewModel-resolving wrapper plus a
previewable MainContent, rather than having MainScreen call
hiltViewModel() directly - preserves the interactive Android Studio
preview from last session, which a direct Hilt dependency would have
broken.

media3 pinned to 1.11.1 (built against Kotlin 2.2.0, matching this
project's 2.2.10 - learned from Phase 3's Coil version conflict to
check this before picking a version this time).

Verified extensively on-device against the real Navidrome server:
real tracks play with correct metadata/artwork, pause/resume and
queue auto-advance work, the real Android MediaSession exposes
correct state (checked independently via `dumpsys media_session`,
not just the app's own UI), background playback survives
foregrounding/backgrounding, and no crashes across the whole session.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
christopher
2026-09-17 04:26:55 -04:00
co-authored by Claude Sonnet 5
parent 08b3a9a0df
commit cf52bb854e
22 changed files with 757 additions and 22 deletions
+5
View File
@@ -72,6 +72,11 @@ dependencies {
implementation(libs.coil.compose) implementation(libs.coil.compose)
implementation(libs.coil.network.okhttp) implementation(libs.coil.network.okhttp)
// Playback (shares the signed OkHttpClient from NetworkModule)
implementation(libs.androidx.media3.exoplayer)
implementation(libs.androidx.media3.session)
implementation(libs.androidx.media3.datasource.okhttp)
testImplementation(libs.junit) testImplementation(libs.junit)
testImplementation(libs.okhttp.mockwebserver) testImplementation(libs.okhttp.mockwebserver)
testImplementation(libs.mockito.core) testImplementation(libs.mockito.core)
+13
View File
@@ -3,6 +3,10 @@
xmlns:tools="http://schemas.android.com/tools"> xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<application <application
android:name=".DeepwaveApplication" android:name=".DeepwaveApplication"
@@ -27,6 +31,15 @@
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
</intent-filter> </intent-filter>
</activity> </activity>
<service
android:name=".media.DeepwavePlaybackService"
android:foregroundServiceType="mediaPlayback"
android:exported="true">
<intent-filter>
<action android:name="androidx.media3.session.MediaSessionService" />
</intent-filter>
</service>
</application> </application>
</manifest> </manifest>
@@ -1,22 +1,42 @@
package com.InfernalAquatics.deepwave package com.InfernalAquatics.deepwave
import android.Manifest
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle import android.os.Bundle
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.content.ContextCompat
import com.InfernalAquatics.deepwave.ui.navigation.DeepwaveNavHost import com.InfernalAquatics.deepwave.ui.navigation.DeepwaveNavHost
import com.InfernalAquatics.deepwave.ui.theme.DeepwaveTheme import com.InfernalAquatics.deepwave.ui.theme.DeepwaveTheme
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
@AndroidEntryPoint @AndroidEntryPoint
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
private val notificationPermissionLauncher =
registerForActivityResult(ActivityResultContracts.RequestPermission()) { /* playback works either way; only the notification is affected */ }
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
enableEdgeToEdge() enableEdgeToEdge()
requestNotificationPermissionIfNeeded()
setContent { setContent {
DeepwaveTheme { DeepwaveTheme {
DeepwaveNavHost() DeepwaveNavHost()
} }
} }
} }
/** Without this on API 33+, the playback service still plays but its notification never shows. */
private fun requestNotificationPermissionIfNeeded() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return
val granted = ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) ==
PackageManager.PERMISSION_GRANTED
if (!granted) {
notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
}
}
} }
@@ -0,0 +1,11 @@
package com.InfernalAquatics.deepwave.data.network
import com.InfernalAquatics.deepwave.di.PLACEHOLDER_BASE_URL
/**
* Builds a playback request URL for ExoPlayer, the same way [coverArtUrl] does for Coil:
* [SubsonicRequestInterceptor] rewrites and signs it, and ExoPlayer's OkHttpDataSource shares
* the same OkHttpClient (see DeepwavePlaybackService), so this resolves like every other
* Subsonic request.
*/
fun streamUrl(songId: String): String = "${PLACEHOLDER_BASE_URL}rest/stream.view?id=$songId"
@@ -42,4 +42,15 @@ interface SubsonicApi {
@Query("albumCount") albumCount: Int = 10, @Query("albumCount") albumCount: Int = 10,
@Query("songCount") songCount: Int = 10, @Query("songCount") songCount: Int = 10,
): SearchResult3Response ): SearchResult3Response
/**
* `submission=false` marks the song "now playing" for other clients; `submission=true`
* (the default here) records a real play, feeding Navidrome's play-count data that
* [getAlbumList2]'s `frequent`/`recent` rows are built from.
*/
@GET("rest/scrobble")
suspend fun scrobble(
@Query("id") id: String,
@Query("submission") submission: Boolean = true,
): PingResponse
} }
@@ -0,0 +1,100 @@
package com.InfernalAquatics.deepwave.media
import android.app.PendingIntent
import android.content.Intent
import androidx.media3.common.MediaItem
import androidx.media3.common.Player
import androidx.media3.common.util.UnstableApi
import androidx.media3.datasource.ResolvingDataSource
import androidx.media3.datasource.okhttp.OkHttpDataSource
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
import androidx.media3.session.MediaLibraryService
import androidx.media3.session.MediaSession
import com.InfernalAquatics.deepwave.MainActivity
import com.InfernalAquatics.deepwave.data.network.SubsonicApi
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import okhttp3.OkHttpClient
import javax.inject.Inject
/**
* Owns the app's single [ExoPlayer] + [MediaLibrarySession]. Built as a [MediaLibraryService]
* from day one (rather than the plainer [androidx.media3.session.MediaSessionService]) even
* though the browse tree is a stub for now - it's a supertype-compatible superset, so Android
* Auto (a later phase) only has to add browse-tree content here, never rebuild the service.
*/
@UnstableApi
@AndroidEntryPoint
class DeepwavePlaybackService : MediaLibraryService() {
@Inject lateinit var okHttpClient: OkHttpClient
@Inject lateinit var localOrRemoteDataSource: LocalOrRemoteDataSource
@Inject lateinit var subsonicApi: SubsonicApi
private lateinit var player: ExoPlayer
private lateinit var mediaLibrarySession: MediaLibrarySession
private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
override fun onCreate() {
super.onCreate()
val upstreamFactory = OkHttpDataSource.Factory(okHttpClient)
val resolvingFactory = ResolvingDataSource.Factory(upstreamFactory, localOrRemoteDataSource)
val mediaSourceFactory = DefaultMediaSourceFactory(this).setDataSourceFactory(resolvingFactory)
player = ExoPlayer.Builder(this)
.setMediaSourceFactory(mediaSourceFactory)
.build()
player.addListener(ScrobblingListener())
val sessionActivityIntent = PendingIntent.getActivity(
this,
0,
Intent(this, MainActivity::class.java),
PendingIntent.FLAG_IMMUTABLE,
)
mediaLibrarySession = MediaLibrarySession.Builder(this, player, StubLibrarySessionCallback())
.setSessionActivity(sessionActivityIntent)
.build()
}
override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaLibrarySession =
mediaLibrarySession
override fun onDestroy() {
serviceScope.cancel()
mediaLibrarySession.release()
player.release()
super.onDestroy()
}
/** Root/browse tree is filled in when Android Auto is added; until then this just denies browsing. */
private class StubLibrarySessionCallback : MediaLibrarySession.Callback
/**
* `submission=false` on transition-in marks the new song "now playing"; `submission=true`
* on transition-out records the play. A simplified heuristic (scrobble the outgoing item
* whenever playback moves on) rather than tracking a played-percentage threshold.
*/
private inner class ScrobblingListener : Player.Listener {
private var lastSongId: String? = null
override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) {
val previousSongId = lastSongId
val newSongId = mediaItem?.mediaId?.let(::songIdFromMediaId)
lastSongId = newSongId
serviceScope.launch {
runCatching {
previousSongId?.let { subsonicApi.scrobble(id = it, submission = true) }
newSongId?.let { subsonicApi.scrobble(id = it, submission = false) }
}
}
}
}
}
@@ -0,0 +1,26 @@
package com.InfernalAquatics.deepwave.media
import android.net.Uri
import androidx.media3.common.util.UnstableApi
import androidx.media3.datasource.DataSpec
import androidx.media3.datasource.ResolvingDataSource
import java.io.File
import javax.inject.Inject
/**
* The entire "prefer a downloaded file over streaming" mechanism, reused unmodified by both
* in-app playback and (later) Android Auto since both play through the same
* [DeepwavePlaybackService]. The song id is read straight off the stream URL's `id` query
* param (set by [com.InfernalAquatics.deepwave.data.network.streamUrl]) rather than plumbing
* a separate cache key through Media3's APIs.
*/
@UnstableApi
class LocalOrRemoteDataSource @Inject constructor(
private val localTrackFiles: LocalTrackFiles,
) : ResolvingDataSource.Resolver {
override fun resolveDataSpec(dataSpec: DataSpec): DataSpec {
val songId = dataSpec.uri.getQueryParameter("id") ?: return dataSpec
val localPath = localTrackFiles.localFilePath(songId) ?: return dataSpec
return dataSpec.withUri(Uri.fromFile(File(localPath)))
}
}
@@ -0,0 +1,22 @@
package com.InfernalAquatics.deepwave.media
import kotlinx.coroutines.flow.MutableStateFlow
import javax.inject.Inject
import javax.inject.Singleton
/**
* Which songs have a downloaded local file, keyed by Subsonic song id. Empty until Phase 5
* wires in a Room-backed download index that calls [setLocalFiles] whenever it changes.
* Read synchronously by [LocalOrRemoteDataSource] on the playback thread, so this is a plain
* in-memory map rather than something that requires a suspend/blocking read.
*/
@Singleton
class LocalTrackFiles @Inject constructor() {
private val filesBySongId = MutableStateFlow<Map<String, String>>(emptyMap())
fun setLocalFiles(filesBySongId: Map<String, String>) {
this.filesBySongId.value = filesBySongId
}
fun localFilePath(songId: String): String? = filesBySongId.value[songId]
}
@@ -0,0 +1,143 @@
package com.InfernalAquatics.deepwave.media
import android.content.ComponentName
import android.content.Context
import androidx.media3.common.MediaItem
import androidx.media3.common.MediaMetadata
import androidx.media3.common.Player
import androidx.media3.common.util.UnstableApi
import androidx.media3.session.MediaController
import androidx.media3.session.SessionToken
import com.google.common.util.concurrent.ListenableFuture
import com.google.common.util.concurrent.MoreExecutors
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.suspendCancellableCoroutine
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
data class NowPlaying(
val mediaId: String?,
val title: String?,
val artist: String?,
val artworkUri: String?,
)
data class PlaybackUiState(
val nowPlaying: NowPlaying? = null,
val isPlaying: Boolean = false,
val positionMs: Long = 0L,
val durationMs: Long = 0L,
)
/**
* App-facing facade over a [MediaController] connected to [DeepwavePlaybackService] - the same
* facade convention as [com.InfernalAquatics.deepwave.data.repository.ServerRepository]. UI
* code never touches Media3 session/controller types directly.
*/
@UnstableApi
@Singleton
class PlaybackController @Inject constructor(
@ApplicationContext private val context: Context,
) {
private var controller: MediaController? = null
private val _uiState = MutableStateFlow(PlaybackUiState())
val uiState: StateFlow<PlaybackUiState> = _uiState.asStateFlow()
private val playerListener = object : Player.Listener {
override fun onIsPlayingChanged(isPlaying: Boolean) {
_uiState.update { it.copy(isPlaying = isPlaying) }
}
override fun onMediaMetadataChanged(mediaMetadata: MediaMetadata) {
updateNowPlaying()
}
}
suspend fun ensureConnected() {
if (controller != null) return
val sessionToken = SessionToken(context, ComponentName(context, DeepwavePlaybackService::class.java))
val newController = MediaController.Builder(context, sessionToken).buildAsync().await()
newController.addListener(playerListener)
controller = newController
updateNowPlaying()
_uiState.update {
it.copy(
isPlaying = newController.isPlaying,
positionMs = newController.currentPosition,
durationMs = newController.duration.coerceAtLeast(0),
)
}
}
fun playQueue(items: List<MediaItem>, startIndex: Int) {
val activeController = controller ?: return
activeController.setMediaItems(items, startIndex, 0L)
activeController.prepare()
activeController.play()
}
fun togglePlayPause() {
val activeController = controller ?: return
if (activeController.isPlaying) activeController.pause() else activeController.play()
}
fun seekTo(positionMs: Long) {
controller?.seekTo(positionMs)
}
fun skipToNext() {
controller?.seekToNext()
}
fun skipToPrevious() {
controller?.seekToPrevious()
}
/** Position/duration aren't pushed by [Player.Listener] on a timer - poll this while playing. */
fun refreshPosition() {
val activeController = controller ?: return
_uiState.update {
it.copy(
positionMs = activeController.currentPosition,
durationMs = activeController.duration.coerceAtLeast(0),
)
}
}
private fun updateNowPlaying() {
val item = controller?.currentMediaItem
_uiState.update {
it.copy(
nowPlaying = item?.let { mediaItem ->
NowPlaying(
mediaId = mediaItem.mediaId,
title = mediaItem.mediaMetadata.title?.toString(),
artist = mediaItem.mediaMetadata.artist?.toString(),
artworkUri = mediaItem.mediaMetadata.artworkUri?.toString(),
)
},
)
}
}
}
private suspend fun <T> ListenableFuture<T>.await(): T = suspendCancellableCoroutine { continuation ->
addListener(
{
try {
continuation.resume(get())
} catch (e: Exception) {
continuation.resumeWithException(e)
}
},
MoreExecutors.directExecutor(),
)
continuation.invokeOnCancellation { cancel(false) }
}
@@ -0,0 +1,33 @@
package com.InfernalAquatics.deepwave.media
import android.net.Uri
import androidx.media3.common.MediaItem
import androidx.media3.common.MediaMetadata
import com.InfernalAquatics.deepwave.data.model.Song
import com.InfernalAquatics.deepwave.data.network.coverArtUrl
import com.InfernalAquatics.deepwave.data.network.streamUrl
private const val MEDIA_ID_PREFIX = "subsonic:song:"
fun songMediaId(songId: String): String = "$MEDIA_ID_PREFIX$songId"
/** Strips the [songMediaId] prefix back down to the raw Subsonic song id, e.g. for scrobbling. */
fun songIdFromMediaId(mediaId: String): String? =
mediaId.removePrefix(MEDIA_ID_PREFIX).takeIf { it != mediaId }
fun Song.toMediaItem(): MediaItem {
val metadata = MediaMetadata.Builder()
.setTitle(title)
.setArtist(artistName)
.setAlbumTitle(albumName)
.apply {
coverArtUrl(coverArtId)?.let { setArtworkUri(Uri.parse(it)) }
}
.build()
return MediaItem.Builder()
.setMediaId(songMediaId(id))
.setUri(streamUrl(id))
.setMediaMetadata(metadata)
.build()
}
@@ -2,10 +2,13 @@ package com.InfernalAquatics.deepwave.ui.components
import android.content.res.Configuration import android.content.res.Configuration
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Pause
import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
@@ -15,39 +18,73 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.InfernalAquatics.deepwave.R import com.InfernalAquatics.deepwave.R
import com.InfernalAquatics.deepwave.ui.theme.DeepwaveTheme import com.InfernalAquatics.deepwave.ui.theme.DeepwaveTheme
/** Placeholder now-playing bar; wired to real PlaybackController state in Phase 4. */
@Composable @Composable
fun MiniPlayerBar(modifier: Modifier = Modifier) { fun MiniPlayerBar(
title: String?,
subtitle: String?,
artworkUrl: String?,
isPlaying: Boolean,
onTogglePlayPause: () -> Unit,
modifier: Modifier = Modifier,
onClick: () -> Unit = {},
) {
Row( Row(
modifier = modifier modifier = modifier
.fillMaxWidth() .fillMaxWidth()
.background(MaterialTheme.colorScheme.surfaceVariant) .background(MaterialTheme.colorScheme.surfaceVariant)
.clickable(enabled = title != null, onClick = onClick)
.padding(horizontal = 16.dp, vertical = 8.dp), .padding(horizontal = 16.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
ArtworkPlaceholder(size = 40.dp) Artwork(imageUrl = artworkUrl, size = 40.dp)
Text( Column(
text = stringResource(R.string.player_nothing_playing),
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier modifier = Modifier
.weight(1f) .weight(1f)
.padding(start = 12.dp), .padding(start = 12.dp),
) ) {
IconButton(onClick = {}, enabled = false) { Text(
Icon(Icons.Filled.PlayArrow, contentDescription = stringResource(R.string.player_play)) text = title ?: stringResource(R.string.player_nothing_playing),
style = MaterialTheme.typography.bodyMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
if (subtitle != null) {
Text(
text = subtitle,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
IconButton(onClick = onTogglePlayPause, enabled = title != null) {
Icon(
imageVector = if (isPlaying) Icons.Filled.Pause else Icons.Filled.PlayArrow,
contentDescription = stringResource(if (isPlaying) R.string.player_pause else R.string.player_play),
)
} }
} }
} }
@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES) @Preview(name = "Nothing playing", showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable @Composable
private fun MiniPlayerBarPreview() { private fun MiniPlayerBarEmptyPreview() {
DeepwaveTheme { DeepwaveTheme {
MiniPlayerBar() MiniPlayerBar(title = null, subtitle = null, artworkUrl = null, isPlaying = false, onTogglePlayPause = {})
}
}
@Preview(name = "Playing", showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun MiniPlayerBarPlayingPreview() {
DeepwaveTheme {
MiniPlayerBar(title = "Sample Track", subtitle = "Sample Artist", artworkUrl = null, isPlaying = true, onTogglePlayPause = {})
} }
} }
@@ -9,7 +9,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
@@ -30,6 +30,7 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.media3.common.util.UnstableApi
import com.InfernalAquatics.deepwave.R import com.InfernalAquatics.deepwave.R
import com.InfernalAquatics.deepwave.data.model.Album import com.InfernalAquatics.deepwave.data.model.Album
import com.InfernalAquatics.deepwave.data.model.Song import com.InfernalAquatics.deepwave.data.model.Song
@@ -38,18 +39,19 @@ import com.InfernalAquatics.deepwave.ui.components.Artwork
import com.InfernalAquatics.deepwave.ui.components.TrackRow import com.InfernalAquatics.deepwave.ui.components.TrackRow
import com.InfernalAquatics.deepwave.ui.theme.DeepwaveTheme import com.InfernalAquatics.deepwave.ui.theme.DeepwaveTheme
@UnstableApi
@Composable @Composable
fun AlbumDetailScreen( fun AlbumDetailScreen(
onBack: () -> Unit, onBack: () -> Unit,
viewModel: AlbumDetailViewModel = hiltViewModel(), viewModel: AlbumDetailViewModel = hiltViewModel(),
) { ) {
val uiState by viewModel.uiState.collectAsState() val uiState by viewModel.uiState.collectAsState()
AlbumDetailContent(uiState = uiState, onBack = onBack) AlbumDetailContent(uiState = uiState, onBack = onBack, onSongClick = viewModel::playSong)
} }
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
private fun AlbumDetailContent(uiState: AlbumDetailUiState, onBack: () -> Unit) { private fun AlbumDetailContent(uiState: AlbumDetailUiState, onBack: () -> Unit, onSongClick: (Int) -> Unit = {}) {
Scaffold( Scaffold(
topBar = { topBar = {
TopAppBar( TopAppBar(
@@ -72,11 +74,12 @@ private fun AlbumDetailContent(uiState: AlbumDetailUiState, onBack: () -> Unit)
} }
else -> LazyColumn(modifier = Modifier.fillMaxSize()) { else -> LazyColumn(modifier = Modifier.fillMaxSize()) {
item { AlbumHeader(uiState.album) } item { AlbumHeader(uiState.album) }
items(uiState.songs, key = { it.id }) { song -> itemsIndexed(uiState.songs, key = { _, song -> song.id }) { index, song ->
TrackRow( TrackRow(
title = song.title, title = song.title,
subtitle = song.artistName ?: uiState.album?.artistName.orEmpty(), subtitle = song.artistName ?: uiState.album?.artistName.orEmpty(),
coverArtUrl = coverArtUrl(song.coverArtId ?: uiState.album?.coverArtId), coverArtUrl = coverArtUrl(song.coverArtId ?: uiState.album?.coverArtId),
onClick = { onSongClick(index) },
) )
} }
} }
@@ -3,10 +3,13 @@ package com.InfernalAquatics.deepwave.ui.library
import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import androidx.media3.common.util.UnstableApi
import androidx.navigation.toRoute import androidx.navigation.toRoute
import com.InfernalAquatics.deepwave.data.model.Album import com.InfernalAquatics.deepwave.data.model.Album
import com.InfernalAquatics.deepwave.data.model.Song import com.InfernalAquatics.deepwave.data.model.Song
import com.InfernalAquatics.deepwave.data.repository.LibraryRepository import com.InfernalAquatics.deepwave.data.repository.LibraryRepository
import com.InfernalAquatics.deepwave.media.PlaybackController
import com.InfernalAquatics.deepwave.media.toMediaItem
import com.InfernalAquatics.deepwave.ui.navigation.Route import com.InfernalAquatics.deepwave.ui.navigation.Route
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@@ -25,10 +28,12 @@ data class AlbumDetailUiState(
val errorMessage: String? = null, val errorMessage: String? = null,
) )
@UnstableApi
@HiltViewModel @HiltViewModel
class AlbumDetailViewModel @Inject constructor( class AlbumDetailViewModel @Inject constructor(
savedStateHandle: SavedStateHandle, savedStateHandle: SavedStateHandle,
private val libraryRepository: LibraryRepository, private val libraryRepository: LibraryRepository,
private val playbackController: PlaybackController,
) : ViewModel() { ) : ViewModel() {
private val albumId = savedStateHandle.toRoute<Route.AlbumDetail>().albumId private val albumId = savedStateHandle.toRoute<Route.AlbumDetail>().albumId
@@ -39,6 +44,15 @@ class AlbumDetailViewModel @Inject constructor(
load() load()
} }
fun playSong(index: Int) {
val songs = _uiState.value.songs
if (index !in songs.indices) return
viewModelScope.launch {
playbackController.ensureConnected()
playbackController.playQueue(songs.map { it.toMediaItem() }, startIndex = index)
}
}
private fun load() { private fun load() {
viewModelScope.launch { viewModelScope.launch {
_uiState.update { it.copy(isLoading = true, errorMessage = null) } _uiState.update { it.copy(isLoading = true, errorMessage = null) }
@@ -51,6 +51,7 @@ fun SearchScreen(
onQueryChange = viewModel::onQueryChange, onQueryChange = viewModel::onQueryChange,
onArtistClick = onArtistClick, onArtistClick = onArtistClick,
onAlbumClick = onAlbumClick, onAlbumClick = onAlbumClick,
onSongClick = viewModel::playSong,
modifier = modifier, modifier = modifier,
) )
} }
@@ -61,6 +62,7 @@ private fun SearchContent(
onQueryChange: (String) -> Unit, onQueryChange: (String) -> Unit,
onArtistClick: (String) -> Unit, onArtistClick: (String) -> Unit,
onAlbumClick: (String) -> Unit, onAlbumClick: (String) -> Unit,
onSongClick: (Song) -> Unit = {},
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
Column(modifier = modifier.fillMaxSize()) { Column(modifier = modifier.fillMaxSize()) {
@@ -86,7 +88,12 @@ private fun SearchContent(
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text(text = stringResource(R.string.search_no_results), style = MaterialTheme.typography.bodyLarge) Text(text = stringResource(R.string.search_no_results), style = MaterialTheme.typography.bodyLarge)
} }
results != null -> SearchResultsList(results = results, onArtistClick = onArtistClick, onAlbumClick = onAlbumClick) results != null -> SearchResultsList(
results = results,
onArtistClick = onArtistClick,
onAlbumClick = onAlbumClick,
onSongClick = onSongClick,
)
} }
} }
} }
@@ -96,6 +103,7 @@ private fun SearchResultsList(
results: SearchResults, results: SearchResults,
onArtistClick: (String) -> Unit, onArtistClick: (String) -> Unit,
onAlbumClick: (String) -> Unit, onAlbumClick: (String) -> Unit,
onSongClick: (Song) -> Unit,
) { ) {
LazyColumn(modifier = Modifier.fillMaxSize()) { LazyColumn(modifier = Modifier.fillMaxSize()) {
if (results.artists.isNotEmpty()) { if (results.artists.isNotEmpty()) {
@@ -140,6 +148,7 @@ private fun SearchResultsList(
title = song.title, title = song.title,
subtitle = song.artistName.orEmpty(), subtitle = song.artistName.orEmpty(),
coverArtUrl = coverArtUrl(song.coverArtId), coverArtUrl = coverArtUrl(song.coverArtId),
onClick = { onSongClick(song) },
) )
} }
} }
@@ -2,8 +2,12 @@ package com.InfernalAquatics.deepwave.ui.library
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import androidx.media3.common.util.UnstableApi
import com.InfernalAquatics.deepwave.data.model.Song
import com.InfernalAquatics.deepwave.data.repository.LibraryRepository import com.InfernalAquatics.deepwave.data.repository.LibraryRepository
import com.InfernalAquatics.deepwave.data.repository.SearchResults import com.InfernalAquatics.deepwave.data.repository.SearchResults
import com.InfernalAquatics.deepwave.media.PlaybackController
import com.InfernalAquatics.deepwave.media.toMediaItem
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
@@ -25,15 +29,24 @@ data class SearchUiState(
val errorMessage: String? = null, val errorMessage: String? = null,
) )
@UnstableApi
@HiltViewModel @HiltViewModel
class SearchViewModel @Inject constructor( class SearchViewModel @Inject constructor(
private val libraryRepository: LibraryRepository, private val libraryRepository: LibraryRepository,
private val playbackController: PlaybackController,
) : ViewModel() { ) : ViewModel() {
private val _uiState = MutableStateFlow(SearchUiState()) private val _uiState = MutableStateFlow(SearchUiState())
val uiState: StateFlow<SearchUiState> = _uiState.asStateFlow() val uiState: StateFlow<SearchUiState> = _uiState.asStateFlow()
private var searchJob: Job? = null private var searchJob: Job? = null
fun playSong(song: Song) {
viewModelScope.launch {
playbackController.ensureConnected()
playbackController.playQueue(listOf(song.toMediaItem()), startIndex = 0)
}
}
fun onQueryChange(query: String) { fun onQueryChange(query: String) {
_uiState.update { it.copy(query = query, errorMessage = null) } _uiState.update { it.copy(query = query, errorMessage = null) }
searchJob?.cancel() searchJob?.cancel()
@@ -9,6 +9,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.media3.common.util.UnstableApi
import androidx.navigation.NavHostController import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
@@ -16,8 +17,10 @@ import androidx.navigation.compose.rememberNavController
import com.InfernalAquatics.deepwave.ui.library.AlbumDetailScreen import com.InfernalAquatics.deepwave.ui.library.AlbumDetailScreen
import com.InfernalAquatics.deepwave.ui.library.ArtistDetailScreen import com.InfernalAquatics.deepwave.ui.library.ArtistDetailScreen
import com.InfernalAquatics.deepwave.ui.login.LoginScreen import com.InfernalAquatics.deepwave.ui.login.LoginScreen
import com.InfernalAquatics.deepwave.ui.player.NowPlayingScreen
import com.InfernalAquatics.deepwave.ui.settings.SettingsScreen import com.InfernalAquatics.deepwave.ui.settings.SettingsScreen
@UnstableApi
@Composable @Composable
fun DeepwaveNavHost( fun DeepwaveNavHost(
navController: NavHostController = rememberNavController(), navController: NavHostController = rememberNavController(),
@@ -47,6 +50,7 @@ fun DeepwaveNavHost(
onOpenSettings = { navController.navigate(Route.Settings) }, onOpenSettings = { navController.navigate(Route.Settings) },
onOpenArtist = { artistId -> navController.navigate(Route.ArtistDetail(artistId)) }, onOpenArtist = { artistId -> navController.navigate(Route.ArtistDetail(artistId)) },
onOpenAlbum = { albumId -> navController.navigate(Route.AlbumDetail(albumId)) }, onOpenAlbum = { albumId -> navController.navigate(Route.AlbumDetail(albumId)) },
onOpenNowPlaying = { navController.navigate(Route.NowPlaying) },
) )
} }
composable<Route.Settings> { composable<Route.Settings> {
@@ -68,6 +72,9 @@ fun DeepwaveNavHost(
composable<Route.AlbumDetail> { composable<Route.AlbumDetail> {
AlbumDetailScreen(onBack = { navController.popBackStack() }) AlbumDetailScreen(onBack = { navController.popBackStack() })
} }
composable<Route.NowPlaying> {
NowPlayingScreen(onBack = { navController.popBackStack() })
}
} }
} }
} }
@@ -14,6 +14,7 @@ import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
@@ -21,22 +22,50 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.media3.common.util.UnstableApi
import com.InfernalAquatics.deepwave.R import com.InfernalAquatics.deepwave.R
import com.InfernalAquatics.deepwave.media.PlaybackUiState
import com.InfernalAquatics.deepwave.ui.components.BottomNavBar import com.InfernalAquatics.deepwave.ui.components.BottomNavBar
import com.InfernalAquatics.deepwave.ui.components.BottomTab import com.InfernalAquatics.deepwave.ui.components.BottomTab
import com.InfernalAquatics.deepwave.ui.components.MiniPlayerBar import com.InfernalAquatics.deepwave.ui.components.MiniPlayerBar
import com.InfernalAquatics.deepwave.ui.library.ArtistsScreen import com.InfernalAquatics.deepwave.ui.library.ArtistsScreen
import com.InfernalAquatics.deepwave.ui.library.HomeScreen import com.InfernalAquatics.deepwave.ui.library.HomeScreen
import com.InfernalAquatics.deepwave.ui.library.SearchScreen import com.InfernalAquatics.deepwave.ui.library.SearchScreen
import com.InfernalAquatics.deepwave.ui.player.PlayerViewModel
import com.InfernalAquatics.deepwave.ui.theme.DeepwaveTheme import com.InfernalAquatics.deepwave.ui.theme.DeepwaveTheme
/** Hosts the bottom-tab shell (Home / Search / Library) plus the mini-player bar and settings entry point. */ /** Resolves the real player state, then hands off to the previewable [MainContent]. */
@OptIn(ExperimentalMaterial3Api::class) @UnstableApi
@Composable @Composable
fun MainScreen( fun MainScreen(
onOpenSettings: () -> Unit, onOpenSettings: () -> Unit,
onOpenArtist: (String) -> Unit, onOpenArtist: (String) -> Unit,
onOpenAlbum: (String) -> Unit, onOpenAlbum: (String) -> Unit,
onOpenNowPlaying: () -> Unit,
playerViewModel: PlayerViewModel = hiltViewModel(),
) {
val playbackUiState by playerViewModel.uiState.collectAsState()
MainContent(
playbackUiState = playbackUiState,
onOpenSettings = onOpenSettings,
onOpenArtist = onOpenArtist,
onOpenAlbum = onOpenAlbum,
onOpenNowPlaying = onOpenNowPlaying,
onTogglePlayPause = playerViewModel::togglePlayPause,
)
}
/** Hosts the bottom-tab shell (Home / Search / Library) plus the mini-player bar and settings entry point. */
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun MainContent(
playbackUiState: PlaybackUiState,
onOpenSettings: () -> Unit,
onOpenArtist: (String) -> Unit,
onOpenAlbum: (String) -> Unit,
onOpenNowPlaying: () -> Unit,
onTogglePlayPause: () -> Unit,
) { ) {
var selectedTab by remember { mutableStateOf(BottomTab.Home) } var selectedTab by remember { mutableStateOf(BottomTab.Home) }
@@ -53,7 +82,14 @@ fun MainScreen(
}, },
bottomBar = { bottomBar = {
Column { Column {
MiniPlayerBar() MiniPlayerBar(
title = playbackUiState.nowPlaying?.title,
subtitle = playbackUiState.nowPlaying?.artist,
artworkUrl = playbackUiState.nowPlaying?.artworkUri,
isPlaying = playbackUiState.isPlaying,
onTogglePlayPause = onTogglePlayPause,
onClick = onOpenNowPlaying,
)
BottomNavBar(selectedTab = selectedTab, onTabSelected = { selectedTab = it }) BottomNavBar(selectedTab = selectedTab, onTabSelected = { selectedTab = it })
} }
}, },
@@ -75,8 +111,15 @@ fun MainScreen(
/** Interactive in Android Studio's preview pane — try tapping the tabs and the settings icon. */ /** Interactive in Android Studio's preview pane — try tapping the tabs and the settings icon. */
@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES, widthDp = 411, heightDp = 891) @Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES, widthDp = 411, heightDp = 891)
@Composable @Composable
private fun MainScreenPreview() { private fun MainContentPreview() {
DeepwaveTheme { DeepwaveTheme {
MainScreen(onOpenSettings = {}, onOpenArtist = {}, onOpenAlbum = {}) MainContent(
playbackUiState = PlaybackUiState(),
onOpenSettings = {},
onOpenArtist = {},
onOpenAlbum = {},
onOpenNowPlaying = {},
onTogglePlayPause = {},
)
} }
} }
@@ -8,4 +8,5 @@ sealed interface Route {
@Serializable data object Settings : Route @Serializable data object Settings : Route
@Serializable data class ArtistDetail(val artistId: String) : Route @Serializable data class ArtistDetail(val artistId: String) : Route
@Serializable data class AlbumDetail(val albumId: String) : Route @Serializable data class AlbumDetail(val albumId: String) : Route
@Serializable data object NowPlaying : Route
} }
@@ -0,0 +1,181 @@
package com.InfernalAquatics.deepwave.ui.player
import android.content.res.Configuration
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.KeyboardArrowDown
import androidx.compose.material.icons.filled.Pause
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.SkipNext
import androidx.compose.material.icons.filled.SkipPrevious
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.Slider
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.media3.common.util.UnstableApi
import com.InfernalAquatics.deepwave.media.NowPlaying
import com.InfernalAquatics.deepwave.media.PlaybackUiState
import com.InfernalAquatics.deepwave.ui.components.Artwork
import com.InfernalAquatics.deepwave.ui.theme.DeepwaveTheme
import java.util.Locale
@UnstableApi
@Composable
fun NowPlayingScreen(
onBack: () -> Unit,
viewModel: PlayerViewModel = hiltViewModel(),
) {
val uiState by viewModel.uiState.collectAsState()
NowPlayingContent(
uiState = uiState,
onBack = onBack,
onTogglePlayPause = viewModel::togglePlayPause,
onSeek = viewModel::seekTo,
onSkipNext = viewModel::skipToNext,
onSkipPrevious = viewModel::skipToPrevious,
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun NowPlayingContent(
uiState: PlaybackUiState,
onBack: () -> Unit,
onTogglePlayPause: () -> Unit,
onSeek: (Long) -> Unit,
onSkipNext: () -> Unit,
onSkipPrevious: () -> Unit,
) {
Scaffold(
topBar = {
TopAppBar(
title = {},
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.Filled.KeyboardArrowDown, contentDescription = null)
}
},
)
},
) { innerPadding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding)
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Artwork(imageUrl = uiState.nowPlaying?.artworkUri, size = 280.dp, modifier = Modifier.padding(top = 24.dp))
Spacer(modifier = Modifier.height(32.dp))
Text(
text = uiState.nowPlaying?.title ?: "Nothing playing",
style = MaterialTheme.typography.headlineSmall,
textAlign = TextAlign.Center,
)
uiState.nowPlaying?.artist?.let {
Text(
text = it,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
}
Spacer(modifier = Modifier.height(24.dp))
SeekBar(positionMs = uiState.positionMs, durationMs = uiState.durationMs, onSeek = onSeek)
Spacer(modifier = Modifier.height(16.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceEvenly,
verticalAlignment = Alignment.CenterVertically,
) {
IconButton(onClick = onSkipPrevious) {
Icon(Icons.Filled.SkipPrevious, contentDescription = "Previous", modifier = Modifier.size(40.dp))
}
IconButton(onClick = onTogglePlayPause) {
Icon(
imageVector = if (uiState.isPlaying) Icons.Filled.Pause else Icons.Filled.PlayArrow,
contentDescription = if (uiState.isPlaying) "Pause" else "Play",
modifier = Modifier.size(64.dp),
)
}
IconButton(onClick = onSkipNext) {
Icon(Icons.Filled.SkipNext, contentDescription = "Next", modifier = Modifier.size(40.dp))
}
}
}
}
}
@Composable
private fun SeekBar(positionMs: Long, durationMs: Long, onSeek: (Long) -> Unit) {
var dragPositionMs by remember { mutableStateOf<Long?>(null) }
val displayedPositionMs = dragPositionMs ?: positionMs
val durationForSlider = durationMs.coerceAtLeast(1L)
Column(modifier = Modifier.fillMaxWidth()) {
Slider(
value = displayedPositionMs.toFloat(),
onValueChange = { dragPositionMs = it.toLong() },
onValueChangeFinished = {
dragPositionMs?.let(onSeek)
dragPositionMs = null
},
valueRange = 0f..durationForSlider.toFloat(),
)
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Text(text = formatDuration(displayedPositionMs), style = MaterialTheme.typography.labelSmall)
Text(text = formatDuration(durationMs), style = MaterialTheme.typography.labelSmall)
}
}
}
private fun formatDuration(ms: Long): String {
val totalSeconds = (ms / 1000).coerceAtLeast(0)
val minutes = totalSeconds / 60
val seconds = totalSeconds % 60
return String.format(Locale.getDefault(), "%d:%02d", minutes, seconds)
}
@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES, widthDp = 411, heightDp = 891)
@Composable
private fun NowPlayingContentPreview() {
DeepwaveTheme {
NowPlayingContent(
uiState = PlaybackUiState(
nowPlaying = NowPlaying(mediaId = "subsonic:song:1", title = "Sample Track", artist = "Sample Artist", artworkUri = null),
isPlaying = true,
positionMs = 65_000,
durationMs = 210_000,
),
onBack = {},
onTogglePlayPause = {},
onSeek = {},
onSkipNext = {},
onSkipPrevious = {},
)
}
}
@@ -0,0 +1,38 @@
package com.InfernalAquatics.deepwave.ui.player
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.media3.common.util.UnstableApi
import com.InfernalAquatics.deepwave.media.PlaybackController
import com.InfernalAquatics.deepwave.media.PlaybackUiState
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import javax.inject.Inject
private const val POSITION_POLL_INTERVAL_MS = 500L
@UnstableApi
@HiltViewModel
class PlayerViewModel @Inject constructor(
private val playbackController: PlaybackController,
) : ViewModel() {
val uiState: StateFlow<PlaybackUiState> = playbackController.uiState
init {
viewModelScope.launch { playbackController.ensureConnected() }
viewModelScope.launch {
while (isActive) {
delay(POSITION_POLL_INTERVAL_MS)
playbackController.refreshPosition()
}
}
}
fun togglePlayPause() = playbackController.togglePlayPause()
fun seekTo(positionMs: Long) = playbackController.seekTo(positionMs)
fun skipToNext() = playbackController.skipToNext()
fun skipToPrevious() = playbackController.skipToPrevious()
}
+1
View File
@@ -20,6 +20,7 @@
<string name="player_nothing_playing">Nothing playing</string> <string name="player_nothing_playing">Nothing playing</string>
<string name="player_play">Play</string> <string name="player_play">Play</string>
<string name="player_pause">Pause</string>
<string name="home_row_recently_added">Recently Added</string> <string name="home_row_recently_added">Recently Added</string>
<string name="home_row_recently_played">Recently Played</string> <string name="home_row_recently_played">Recently Played</string>
+4
View File
@@ -19,6 +19,7 @@ retrofitKotlinxSerializationConverter = "1.0.0"
androidxDatastorePreferences = "1.2.1" androidxDatastorePreferences = "1.2.1"
tink = "1.23.0" tink = "1.23.0"
coil = "3.3.0" coil = "3.3.0"
media3 = "1.11.1"
mockitoCore = "5.23.0" mockitoCore = "5.23.0"
kotlinxCoroutinesTest = "1.11.0" kotlinxCoroutinesTest = "1.11.0"
@@ -52,6 +53,9 @@ androidx-datastore-preferences = { group = "androidx.datastore", name = "datasto
tink-android = { group = "com.google.crypto.tink", name = "tink-android", version.ref = "tink" } tink-android = { group = "com.google.crypto.tink", name = "tink-android", version.ref = "tink" }
coil-compose = { group = "io.coil-kt.coil3", name = "coil-compose", version.ref = "coil" } 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" } coil-network-okhttp = { group = "io.coil-kt.coil3", name = "coil-network-okhttp", version.ref = "coil" }
androidx-media3-exoplayer = { group = "androidx.media3", name = "media3-exoplayer", version.ref = "media3" }
androidx-media3-session = { group = "androidx.media3", name = "media3-session", version.ref = "media3" }
androidx-media3-datasource-okhttp = { group = "androidx.media3", name = "media3-datasource-okhttp", version.ref = "media3" }
okhttp-mockwebserver = { group = "com.squareup.okhttp3", name = "mockwebserver3", version.ref = "okhttp" } okhttp-mockwebserver = { group = "com.squareup.okhttp3", name = "mockwebserver3", version.ref = "okhttp" }
mockito-core = { group = "org.mockito", name = "mockito-core", version.ref = "mockitoCore" } mockito-core = { group = "org.mockito", name = "mockito-core", version.ref = "mockitoCore" }
kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "kotlinxCoroutinesTest" } kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "kotlinxCoroutinesTest" }