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