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:
co-authored by
Claude Sonnet 5
parent
08b3a9a0df
commit
cf52bb854e
@@ -72,6 +72,11 @@ dependencies {
|
||||
implementation(libs.coil.compose)
|
||||
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.okhttp.mockwebserver)
|
||||
testImplementation(libs.mockito.core)
|
||||
|
||||
@@ -3,6 +3,10 @@
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<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
|
||||
android:name=".DeepwaveApplication"
|
||||
@@ -27,6 +31,15 @@
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</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>
|
||||
|
||||
</manifest>
|
||||
@@ -1,22 +1,42 @@
|
||||
package com.InfernalAquatics.deepwave
|
||||
|
||||
import android.Manifest
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
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.theme.DeepwaveTheme
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
|
||||
@AndroidEntryPoint
|
||||
class MainActivity : ComponentActivity() {
|
||||
|
||||
private val notificationPermissionLauncher =
|
||||
registerForActivityResult(ActivityResultContracts.RequestPermission()) { /* playback works either way; only the notification is affected */ }
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
enableEdgeToEdge()
|
||||
requestNotificationPermissionIfNeeded()
|
||||
setContent {
|
||||
DeepwaveTheme {
|
||||
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("songCount") songCount: Int = 10,
|
||||
): 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 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.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Pause
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
@@ -15,39 +18,73 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.InfernalAquatics.deepwave.R
|
||||
import com.InfernalAquatics.deepwave.ui.theme.DeepwaveTheme
|
||||
|
||||
/** Placeholder now-playing bar; wired to real PlaybackController state in Phase 4. */
|
||||
@Composable
|
||||
fun MiniPlayerBar(modifier: Modifier = Modifier) {
|
||||
fun MiniPlayerBar(
|
||||
title: String?,
|
||||
subtitle: String?,
|
||||
artworkUrl: String?,
|
||||
isPlaying: Boolean,
|
||||
onTogglePlayPause: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
onClick: () -> Unit = {},
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant)
|
||||
.clickable(enabled = title != null, onClick = onClick)
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
ArtworkPlaceholder(size = 40.dp)
|
||||
Text(
|
||||
text = stringResource(R.string.player_nothing_playing),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
Artwork(imageUrl = artworkUrl, size = 40.dp)
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(start = 12.dp),
|
||||
) {
|
||||
Text(
|
||||
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),
|
||||
)
|
||||
IconButton(onClick = {}, enabled = false) {
|
||||
Icon(Icons.Filled.PlayArrow, contentDescription = stringResource(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
|
||||
private fun MiniPlayerBarPreview() {
|
||||
private fun MiniPlayerBarEmptyPreview() {
|
||||
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.padding
|
||||
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.automirrored.filled.ArrowBack
|
||||
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.unit.dp
|
||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import com.InfernalAquatics.deepwave.R
|
||||
import com.InfernalAquatics.deepwave.data.model.Album
|
||||
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.theme.DeepwaveTheme
|
||||
|
||||
@UnstableApi
|
||||
@Composable
|
||||
fun AlbumDetailScreen(
|
||||
onBack: () -> Unit,
|
||||
viewModel: AlbumDetailViewModel = hiltViewModel(),
|
||||
) {
|
||||
val uiState by viewModel.uiState.collectAsState()
|
||||
AlbumDetailContent(uiState = uiState, onBack = onBack)
|
||||
AlbumDetailContent(uiState = uiState, onBack = onBack, onSongClick = viewModel::playSong)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun AlbumDetailContent(uiState: AlbumDetailUiState, onBack: () -> Unit) {
|
||||
private fun AlbumDetailContent(uiState: AlbumDetailUiState, onBack: () -> Unit, onSongClick: (Int) -> Unit = {}) {
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
@@ -72,11 +74,12 @@ private fun AlbumDetailContent(uiState: AlbumDetailUiState, onBack: () -> Unit)
|
||||
}
|
||||
else -> LazyColumn(modifier = Modifier.fillMaxSize()) {
|
||||
item { AlbumHeader(uiState.album) }
|
||||
items(uiState.songs, key = { it.id }) { song ->
|
||||
itemsIndexed(uiState.songs, key = { _, song -> song.id }) { index, song ->
|
||||
TrackRow(
|
||||
title = song.title,
|
||||
subtitle = song.artistName ?: uiState.album?.artistName.orEmpty(),
|
||||
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.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.navigation.toRoute
|
||||
import com.InfernalAquatics.deepwave.data.model.Album
|
||||
import com.InfernalAquatics.deepwave.data.model.Song
|
||||
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 dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
@@ -25,10 +28,12 @@ data class AlbumDetailUiState(
|
||||
val errorMessage: String? = null,
|
||||
)
|
||||
|
||||
@UnstableApi
|
||||
@HiltViewModel
|
||||
class AlbumDetailViewModel @Inject constructor(
|
||||
savedStateHandle: SavedStateHandle,
|
||||
private val libraryRepository: LibraryRepository,
|
||||
private val playbackController: PlaybackController,
|
||||
) : ViewModel() {
|
||||
private val albumId = savedStateHandle.toRoute<Route.AlbumDetail>().albumId
|
||||
|
||||
@@ -39,6 +44,15 @@ class AlbumDetailViewModel @Inject constructor(
|
||||
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() {
|
||||
viewModelScope.launch {
|
||||
_uiState.update { it.copy(isLoading = true, errorMessage = null) }
|
||||
|
||||
@@ -51,6 +51,7 @@ fun SearchScreen(
|
||||
onQueryChange = viewModel::onQueryChange,
|
||||
onArtistClick = onArtistClick,
|
||||
onAlbumClick = onAlbumClick,
|
||||
onSongClick = viewModel::playSong,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
@@ -61,6 +62,7 @@ private fun SearchContent(
|
||||
onQueryChange: (String) -> Unit,
|
||||
onArtistClick: (String) -> Unit,
|
||||
onAlbumClick: (String) -> Unit,
|
||||
onSongClick: (Song) -> Unit = {},
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Column(modifier = modifier.fillMaxSize()) {
|
||||
@@ -86,7 +88,12 @@ private fun SearchContent(
|
||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
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,
|
||||
onArtistClick: (String) -> Unit,
|
||||
onAlbumClick: (String) -> Unit,
|
||||
onSongClick: (Song) -> Unit,
|
||||
) {
|
||||
LazyColumn(modifier = Modifier.fillMaxSize()) {
|
||||
if (results.artists.isNotEmpty()) {
|
||||
@@ -140,6 +148,7 @@ private fun SearchResultsList(
|
||||
title = song.title,
|
||||
subtitle = song.artistName.orEmpty(),
|
||||
coverArtUrl = coverArtUrl(song.coverArtId),
|
||||
onClick = { onSongClick(song) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,8 +2,12 @@ package com.InfernalAquatics.deepwave.ui.library
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
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.SearchResults
|
||||
import com.InfernalAquatics.deepwave.media.PlaybackController
|
||||
import com.InfernalAquatics.deepwave.media.toMediaItem
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
@@ -25,15 +29,24 @@ data class SearchUiState(
|
||||
val errorMessage: String? = null,
|
||||
)
|
||||
|
||||
@UnstableApi
|
||||
@HiltViewModel
|
||||
class SearchViewModel @Inject constructor(
|
||||
private val libraryRepository: LibraryRepository,
|
||||
private val playbackController: PlaybackController,
|
||||
) : ViewModel() {
|
||||
private val _uiState = MutableStateFlow(SearchUiState())
|
||||
val uiState: StateFlow<SearchUiState> = _uiState.asStateFlow()
|
||||
|
||||
private var searchJob: Job? = null
|
||||
|
||||
fun playSong(song: Song) {
|
||||
viewModelScope.launch {
|
||||
playbackController.ensureConnected()
|
||||
playbackController.playQueue(listOf(song.toMediaItem()), startIndex = 0)
|
||||
}
|
||||
}
|
||||
|
||||
fun onQueryChange(query: String) {
|
||||
_uiState.update { it.copy(query = query, errorMessage = null) }
|
||||
searchJob?.cancel()
|
||||
|
||||
@@ -9,6 +9,7 @@ import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.navigation.NavHostController
|
||||
import androidx.navigation.compose.NavHost
|
||||
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.ArtistDetailScreen
|
||||
import com.InfernalAquatics.deepwave.ui.login.LoginScreen
|
||||
import com.InfernalAquatics.deepwave.ui.player.NowPlayingScreen
|
||||
import com.InfernalAquatics.deepwave.ui.settings.SettingsScreen
|
||||
|
||||
@UnstableApi
|
||||
@Composable
|
||||
fun DeepwaveNavHost(
|
||||
navController: NavHostController = rememberNavController(),
|
||||
@@ -47,6 +50,7 @@ fun DeepwaveNavHost(
|
||||
onOpenSettings = { navController.navigate(Route.Settings) },
|
||||
onOpenArtist = { artistId -> navController.navigate(Route.ArtistDetail(artistId)) },
|
||||
onOpenAlbum = { albumId -> navController.navigate(Route.AlbumDetail(albumId)) },
|
||||
onOpenNowPlaying = { navController.navigate(Route.NowPlaying) },
|
||||
)
|
||||
}
|
||||
composable<Route.Settings> {
|
||||
@@ -68,6 +72,9 @@ fun DeepwaveNavHost(
|
||||
composable<Route.AlbumDetail> {
|
||||
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.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
|
||||
@@ -21,22 +22,50 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
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.media.PlaybackUiState
|
||||
import com.InfernalAquatics.deepwave.ui.components.BottomNavBar
|
||||
import com.InfernalAquatics.deepwave.ui.components.BottomTab
|
||||
import com.InfernalAquatics.deepwave.ui.components.MiniPlayerBar
|
||||
import com.InfernalAquatics.deepwave.ui.library.ArtistsScreen
|
||||
import com.InfernalAquatics.deepwave.ui.library.HomeScreen
|
||||
import com.InfernalAquatics.deepwave.ui.library.SearchScreen
|
||||
import com.InfernalAquatics.deepwave.ui.player.PlayerViewModel
|
||||
import com.InfernalAquatics.deepwave.ui.theme.DeepwaveTheme
|
||||
|
||||
/** Hosts the bottom-tab shell (Home / Search / Library) plus the mini-player bar and settings entry point. */
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
/** Resolves the real player state, then hands off to the previewable [MainContent]. */
|
||||
@UnstableApi
|
||||
@Composable
|
||||
fun MainScreen(
|
||||
onOpenSettings: () -> Unit,
|
||||
onOpenArtist: (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) }
|
||||
|
||||
@@ -53,7 +82,14 @@ fun MainScreen(
|
||||
},
|
||||
bottomBar = {
|
||||
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 })
|
||||
}
|
||||
},
|
||||
@@ -75,8 +111,15 @@ fun MainScreen(
|
||||
/** 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)
|
||||
@Composable
|
||||
private fun MainScreenPreview() {
|
||||
private fun MainContentPreview() {
|
||||
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 class ArtistDetail(val artistId: 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()
|
||||
}
|
||||
@@ -20,6 +20,7 @@
|
||||
|
||||
<string name="player_nothing_playing">Nothing playing</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_played">Recently Played</string>
|
||||
|
||||
@@ -19,6 +19,7 @@ retrofitKotlinxSerializationConverter = "1.0.0"
|
||||
androidxDatastorePreferences = "1.2.1"
|
||||
tink = "1.23.0"
|
||||
coil = "3.3.0"
|
||||
media3 = "1.11.1"
|
||||
mockitoCore = "5.23.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" }
|
||||
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" }
|
||||
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" }
|
||||
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" }
|
||||
|
||||
Reference in New Issue
Block a user