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