Phase 4: Media3 playback engine

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

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

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

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

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
christopher
2026-09-17 04:26:55 -04:00
co-authored by Claude Sonnet 5
parent 08b3a9a0df
commit cf52bb854e
22 changed files with 757 additions and 22 deletions
@@ -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)
}
}
}