From cf52bb854e42f4511be1c96a18f3dd22d2215854 Mon Sep 17 00:00:00 2001 From: christopher Date: Thu, 17 Sep 2026 04:26:55 -0400 Subject: [PATCH] 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 - 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 --- app/build.gradle.kts | 5 + app/src/main/AndroidManifest.xml | 13 ++ .../InfernalAquatics/deepwave/MainActivity.kt | 20 ++ .../deepwave/data/network/StreamUrls.kt | 11 ++ .../deepwave/data/network/SubsonicApi.kt | 11 ++ .../deepwave/media/DeepwavePlaybackService.kt | 100 ++++++++++ .../deepwave/media/LocalOrRemoteDataSource.kt | 26 +++ .../deepwave/media/LocalTrackFiles.kt | 22 +++ .../deepwave/media/PlaybackController.kt | 143 ++++++++++++++ .../deepwave/media/SubsonicMediaItemMapper.kt | 33 ++++ .../deepwave/ui/components/MiniPlayerBar.kt | 61 ++++-- .../deepwave/ui/library/AlbumDetailScreen.kt | 11 +- .../ui/library/AlbumDetailViewModel.kt | 14 ++ .../deepwave/ui/library/SearchScreen.kt | 11 +- .../deepwave/ui/library/SearchViewModel.kt | 13 ++ .../deepwave/ui/navigation/DeepwaveNavHost.kt | 7 + .../deepwave/ui/navigation/MainScreen.kt | 53 ++++- .../deepwave/ui/navigation/Route.kt | 1 + .../deepwave/ui/player/NowPlayingScreen.kt | 181 ++++++++++++++++++ .../deepwave/ui/player/PlayerViewModel.kt | 38 ++++ app/src/main/res/values/strings.xml | 1 + gradle/libs.versions.toml | 4 + 22 files changed, 757 insertions(+), 22 deletions(-) create mode 100644 app/src/main/java/com/InfernalAquatics/deepwave/data/network/StreamUrls.kt create mode 100644 app/src/main/java/com/InfernalAquatics/deepwave/media/DeepwavePlaybackService.kt create mode 100644 app/src/main/java/com/InfernalAquatics/deepwave/media/LocalOrRemoteDataSource.kt create mode 100644 app/src/main/java/com/InfernalAquatics/deepwave/media/LocalTrackFiles.kt create mode 100644 app/src/main/java/com/InfernalAquatics/deepwave/media/PlaybackController.kt create mode 100644 app/src/main/java/com/InfernalAquatics/deepwave/media/SubsonicMediaItemMapper.kt create mode 100644 app/src/main/java/com/InfernalAquatics/deepwave/ui/player/NowPlayingScreen.kt create mode 100644 app/src/main/java/com/InfernalAquatics/deepwave/ui/player/PlayerViewModel.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 1a249dd..61dcdbc 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 2de0796..a3bf4bf 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -3,6 +3,10 @@ xmlns:tools="http://schemas.android.com/tools"> + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/java/com/InfernalAquatics/deepwave/MainActivity.kt b/app/src/main/java/com/InfernalAquatics/deepwave/MainActivity.kt index e4df435..7513ecd 100644 --- a/app/src/main/java/com/InfernalAquatics/deepwave/MainActivity.kt +++ b/app/src/main/java/com/InfernalAquatics/deepwave/MainActivity.kt @@ -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) + } + } } diff --git a/app/src/main/java/com/InfernalAquatics/deepwave/data/network/StreamUrls.kt b/app/src/main/java/com/InfernalAquatics/deepwave/data/network/StreamUrls.kt new file mode 100644 index 0000000..8306581 --- /dev/null +++ b/app/src/main/java/com/InfernalAquatics/deepwave/data/network/StreamUrls.kt @@ -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" diff --git a/app/src/main/java/com/InfernalAquatics/deepwave/data/network/SubsonicApi.kt b/app/src/main/java/com/InfernalAquatics/deepwave/data/network/SubsonicApi.kt index ce805a9..6b06177 100644 --- a/app/src/main/java/com/InfernalAquatics/deepwave/data/network/SubsonicApi.kt +++ b/app/src/main/java/com/InfernalAquatics/deepwave/data/network/SubsonicApi.kt @@ -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 } diff --git a/app/src/main/java/com/InfernalAquatics/deepwave/media/DeepwavePlaybackService.kt b/app/src/main/java/com/InfernalAquatics/deepwave/media/DeepwavePlaybackService.kt new file mode 100644 index 0000000..61c4589 --- /dev/null +++ b/app/src/main/java/com/InfernalAquatics/deepwave/media/DeepwavePlaybackService.kt @@ -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) } + } + } + } + } +} diff --git a/app/src/main/java/com/InfernalAquatics/deepwave/media/LocalOrRemoteDataSource.kt b/app/src/main/java/com/InfernalAquatics/deepwave/media/LocalOrRemoteDataSource.kt new file mode 100644 index 0000000..58bff49 --- /dev/null +++ b/app/src/main/java/com/InfernalAquatics/deepwave/media/LocalOrRemoteDataSource.kt @@ -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))) + } +} diff --git a/app/src/main/java/com/InfernalAquatics/deepwave/media/LocalTrackFiles.kt b/app/src/main/java/com/InfernalAquatics/deepwave/media/LocalTrackFiles.kt new file mode 100644 index 0000000..03326f3 --- /dev/null +++ b/app/src/main/java/com/InfernalAquatics/deepwave/media/LocalTrackFiles.kt @@ -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>(emptyMap()) + + fun setLocalFiles(filesBySongId: Map) { + this.filesBySongId.value = filesBySongId + } + + fun localFilePath(songId: String): String? = filesBySongId.value[songId] +} diff --git a/app/src/main/java/com/InfernalAquatics/deepwave/media/PlaybackController.kt b/app/src/main/java/com/InfernalAquatics/deepwave/media/PlaybackController.kt new file mode 100644 index 0000000..3c8529c --- /dev/null +++ b/app/src/main/java/com/InfernalAquatics/deepwave/media/PlaybackController.kt @@ -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 = _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, 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 ListenableFuture.await(): T = suspendCancellableCoroutine { continuation -> + addListener( + { + try { + continuation.resume(get()) + } catch (e: Exception) { + continuation.resumeWithException(e) + } + }, + MoreExecutors.directExecutor(), + ) + continuation.invokeOnCancellation { cancel(false) } +} diff --git a/app/src/main/java/com/InfernalAquatics/deepwave/media/SubsonicMediaItemMapper.kt b/app/src/main/java/com/InfernalAquatics/deepwave/media/SubsonicMediaItemMapper.kt new file mode 100644 index 0000000..6da8a4a --- /dev/null +++ b/app/src/main/java/com/InfernalAquatics/deepwave/media/SubsonicMediaItemMapper.kt @@ -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() +} diff --git a/app/src/main/java/com/InfernalAquatics/deepwave/ui/components/MiniPlayerBar.kt b/app/src/main/java/com/InfernalAquatics/deepwave/ui/components/MiniPlayerBar.kt index e609d90..320800d 100644 --- a/app/src/main/java/com/InfernalAquatics/deepwave/ui/components/MiniPlayerBar.kt +++ b/app/src/main/java/com/InfernalAquatics/deepwave/ui/components/MiniPlayerBar.kt @@ -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), - ) - IconButton(onClick = {}, enabled = false) { - Icon(Icons.Filled.PlayArrow, contentDescription = stringResource(R.string.player_play)) + ) { + 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), + ) } } } -@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 = {}) } } diff --git a/app/src/main/java/com/InfernalAquatics/deepwave/ui/library/AlbumDetailScreen.kt b/app/src/main/java/com/InfernalAquatics/deepwave/ui/library/AlbumDetailScreen.kt index bb88a6a..ae52047 100644 --- a/app/src/main/java/com/InfernalAquatics/deepwave/ui/library/AlbumDetailScreen.kt +++ b/app/src/main/java/com/InfernalAquatics/deepwave/ui/library/AlbumDetailScreen.kt @@ -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) }, ) } } diff --git a/app/src/main/java/com/InfernalAquatics/deepwave/ui/library/AlbumDetailViewModel.kt b/app/src/main/java/com/InfernalAquatics/deepwave/ui/library/AlbumDetailViewModel.kt index 13fbbba..8a23fcd 100644 --- a/app/src/main/java/com/InfernalAquatics/deepwave/ui/library/AlbumDetailViewModel.kt +++ b/app/src/main/java/com/InfernalAquatics/deepwave/ui/library/AlbumDetailViewModel.kt @@ -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().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) } diff --git a/app/src/main/java/com/InfernalAquatics/deepwave/ui/library/SearchScreen.kt b/app/src/main/java/com/InfernalAquatics/deepwave/ui/library/SearchScreen.kt index ec460e6..41598b0 100644 --- a/app/src/main/java/com/InfernalAquatics/deepwave/ui/library/SearchScreen.kt +++ b/app/src/main/java/com/InfernalAquatics/deepwave/ui/library/SearchScreen.kt @@ -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) }, ) } } diff --git a/app/src/main/java/com/InfernalAquatics/deepwave/ui/library/SearchViewModel.kt b/app/src/main/java/com/InfernalAquatics/deepwave/ui/library/SearchViewModel.kt index 5120fbb..0b49569 100644 --- a/app/src/main/java/com/InfernalAquatics/deepwave/ui/library/SearchViewModel.kt +++ b/app/src/main/java/com/InfernalAquatics/deepwave/ui/library/SearchViewModel.kt @@ -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 = _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() diff --git a/app/src/main/java/com/InfernalAquatics/deepwave/ui/navigation/DeepwaveNavHost.kt b/app/src/main/java/com/InfernalAquatics/deepwave/ui/navigation/DeepwaveNavHost.kt index 1318cad..0bf6516 100644 --- a/app/src/main/java/com/InfernalAquatics/deepwave/ui/navigation/DeepwaveNavHost.kt +++ b/app/src/main/java/com/InfernalAquatics/deepwave/ui/navigation/DeepwaveNavHost.kt @@ -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 { @@ -68,6 +72,9 @@ fun DeepwaveNavHost( composable { AlbumDetailScreen(onBack = { navController.popBackStack() }) } + composable { + NowPlayingScreen(onBack = { navController.popBackStack() }) + } } } } diff --git a/app/src/main/java/com/InfernalAquatics/deepwave/ui/navigation/MainScreen.kt b/app/src/main/java/com/InfernalAquatics/deepwave/ui/navigation/MainScreen.kt index 8aa96e7..a325a2e 100644 --- a/app/src/main/java/com/InfernalAquatics/deepwave/ui/navigation/MainScreen.kt +++ b/app/src/main/java/com/InfernalAquatics/deepwave/ui/navigation/MainScreen.kt @@ -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 = {}, + ) } } diff --git a/app/src/main/java/com/InfernalAquatics/deepwave/ui/navigation/Route.kt b/app/src/main/java/com/InfernalAquatics/deepwave/ui/navigation/Route.kt index 935d09e..5bbde3a 100644 --- a/app/src/main/java/com/InfernalAquatics/deepwave/ui/navigation/Route.kt +++ b/app/src/main/java/com/InfernalAquatics/deepwave/ui/navigation/Route.kt @@ -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 } diff --git a/app/src/main/java/com/InfernalAquatics/deepwave/ui/player/NowPlayingScreen.kt b/app/src/main/java/com/InfernalAquatics/deepwave/ui/player/NowPlayingScreen.kt new file mode 100644 index 0000000..9971b55 --- /dev/null +++ b/app/src/main/java/com/InfernalAquatics/deepwave/ui/player/NowPlayingScreen.kt @@ -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(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 = {}, + ) + } +} diff --git a/app/src/main/java/com/InfernalAquatics/deepwave/ui/player/PlayerViewModel.kt b/app/src/main/java/com/InfernalAquatics/deepwave/ui/player/PlayerViewModel.kt new file mode 100644 index 0000000..ca94b3b --- /dev/null +++ b/app/src/main/java/com/InfernalAquatics/deepwave/ui/player/PlayerViewModel.kt @@ -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 = 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() +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 76e8949..9fbb6a9 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -20,6 +20,7 @@ Nothing playing Play + Pause Recently Added Recently Played diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index bd44a16..d9776c0 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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" }