EARHORNMUSIC UPnP · Android TV · Phones · Android Auto · GNU/Linux
Release 0.5.0

LocalLibraryViewModel.kt

276 lines · 11.6 KB

Changed in: 0.5.0

1package dev.stave.ui.local
2
3import android.Manifest
4import android.content.Context
5import android.content.pm.PackageManager
6import android.os.Build
7import androidx.core.content.ContextCompat
8import androidx.lifecycle.ViewModel
9import androidx.lifecycle.viewModelScope
10import dagger.hilt.android.lifecycle.HiltViewModel
11import dagger.hilt.android.qualifiers.ApplicationContext
12import dev.stave.domain.renderer.Renderer
13import dev.stave.local.LocalMediaScanner
14import dev.stave.local.toDidlTrack
15import kotlinx.coroutines.flow.MutableStateFlow
16import kotlinx.coroutines.flow.StateFlow
17import kotlinx.coroutines.flow.asStateFlow
18import kotlinx.coroutines.launch
19import javax.inject.Inject
20
21/**
22 * Drives the on-device music browsing screens (LocalHub / LocalAlbums / LocalAlbum / etc.)
23 * and is the single dispatcher for "play these local tracks now" / "queue these"
24 * / "shuffle these" operations.
25 *
26 * Permission model: API 33+ requires READ_MEDIA_AUDIO at runtime; older API 29-32 accept
27 * READ_EXTERNAL_STORAGE. The relevant permission is exposed via [permissionName] so the UI
28 * can call [androidx.activity.result.contract.ActivityResultContracts.RequestPermission]
29 * with the right string. The screen calls [refreshPermissionState] after the user responds.
30 *
31 * Injection note: uses @ApplicationContext (not Application) because Hilt's @HiltViewModel
32 * binds Context, not Application. Trying to constructor-inject Application here causes
33 * Hilt to fail at ViewModel creation — i.e. the screen would crash on first navigation
34 * with "MissingBinding for android.app.Application".
35 */
36@HiltViewModel
37class LocalLibraryViewModel @Inject constructor(
38 private val playGate: dev.stave.playback.LocalPlaybackGate,
39 @ApplicationContext private val context: Context,
40 private val scanner: LocalMediaScanner,
41 private val renderer: Renderer,
42) : ViewModel() {
43
44 private val _hubState = MutableStateFlow<HubState>(HubState.PermissionRequired)
45 val hubState: StateFlow<HubState> = _hubState.asStateFlow()
46
47 private val _albumsState = MutableStateFlow<AlbumsState>(AlbumsState.Loading)
48 val albumsState: StateFlow<AlbumsState> = _albumsState.asStateFlow()
49
50 private val _artistsState = MutableStateFlow<ArtistsState>(ArtistsState.Loading)
51 val artistsState: StateFlow<ArtistsState> = _artistsState.asStateFlow()
52
53 private val _songsState = MutableStateFlow<SongsState>(SongsState.Loading)
54 val songsState: StateFlow<SongsState> = _songsState.asStateFlow()
55
56 private val _albumDetailState = MutableStateFlow<AlbumDetailState>(AlbumDetailState.Loading)
57 val albumDetailState: StateFlow<AlbumDetailState> = _albumDetailState.asStateFlow()
58
59 private val _artistDetailState = MutableStateFlow<ArtistDetailState>(ArtistDetailState.Loading)
60 val artistDetailState: StateFlow<ArtistDetailState> = _artistDetailState.asStateFlow()
61
62 private val _genreState = MutableStateFlow<GenreState>(GenreState.Loading)
63 val genreState: StateFlow<GenreState> = _genreState.asStateFlow()
64
65 /** Returns the runtime permission to request for media access on this Android version.
66 *
67 * MUST BE DECLARED BEFORE THE init BLOCK BELOW. Kotlin initializes properties in
68 * source order; if init runs first it calls hasPermission() which reads this value
69 * before it has been assigned, throwing NPE "permission must be non-null" inside
70 * ContextCompat.checkSelfPermission. Symptom: every navigation to LocalHubScreen
71 * crashes the process at composable construction time. */
72 val permissionName: String =
73 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) Manifest.permission.READ_MEDIA_AUDIO
74 else Manifest.permission.READ_EXTERNAL_STORAGE
75
76 init {
77 refreshPermissionState()
78 }
79
80 fun hasPermission(): Boolean =
81 ContextCompat.checkSelfPermission(context, permissionName) == PackageManager.PERMISSION_GRANTED
82
83 /** Call this in onResume of LocalHubScreen and after PermissionResult callback so
84 * the hub state reflects the current permission. */
85 fun refreshPermissionState() {
86 if (!hasPermission()) {
87 _hubState.value = HubState.PermissionRequired
88 return
89 }
90 // Permission granted — drop any stale cached scan from a prior pre-grant
91 // attempt (which would have returned 0 tracks) and load summary counts.
92 scanner.invalidateCache()
93 _hubState.value = HubState.Loading
94 viewModelScope.launch {
95 try {
96 val albums = scanner.scanAlbums()
97 val artists = scanner.scanArtists()
98 val songs = scanner.allTracks()
99 _hubState.value = HubState.Ready(
100 albumCount = albums.size,
101 artistCount = artists.size,
102 songCount = songs.size,
103 )
104 } catch (t: Throwable) {
105 // Don't let a scan failure kill the process. Show 0/0/0 so the
106 // hub still renders its empty-state UI.
107 android.util.Log.e("EarhornLocal", "Hub scan failed", t)
108 _hubState.value = HubState.Ready(0, 0, 0)
109 }
110 }
111 }
112
113 fun loadAlbums() {
114 if (!hasPermission()) {
115 _albumsState.value = AlbumsState.PermissionRequired; return
116 }
117 _albumsState.value = AlbumsState.Loading
118 viewModelScope.launch {
119 _albumsState.value = AlbumsState.Ready(scanner.scanAlbums())
120 }
121 }
122
123 fun loadArtists() {
124 if (!hasPermission()) {
125 _artistsState.value = ArtistsState.PermissionRequired; return
126 }
127 _artistsState.value = ArtistsState.Loading
128 viewModelScope.launch {
129 _artistsState.value = ArtistsState.Ready(scanner.scanArtists())
130 }
131 }
132
133 fun loadSongs() {
134 if (!hasPermission()) {
135 _songsState.value = SongsState.PermissionRequired; return
136 }
137 _songsState.value = SongsState.Loading
138 viewModelScope.launch {
139 _songsState.value = SongsState.Ready(scanner.allTracks())
140 }
141 }
142
143 fun loadAlbum(albumId: Long) {
144 if (!hasPermission()) {
145 _albumDetailState.value = AlbumDetailState.PermissionRequired; return
146 }
147 _albumDetailState.value = AlbumDetailState.Loading
148 viewModelScope.launch {
149 val tracks = scanner.tracksForAlbum(albumId)
150 // Pull the album header info from scanAlbums (small list, reasonable to scan again).
151 val album = scanner.scanAlbums().firstOrNull { it.id == albumId }
152 _albumDetailState.value = AlbumDetailState.Ready(album, tracks)
153 }
154 }
155
156 fun loadArtist(artistName: String) {
157 if (!hasPermission()) {
158 _artistDetailState.value = ArtistDetailState.PermissionRequired; return
159 }
160 _artistDetailState.value = ArtistDetailState.Loading
161 viewModelScope.launch {
162 val albums = scanner.albumsForArtist(artistName)
163 val allTracks = scanner.tracksForArtist(artistName)
164 _artistDetailState.value = ArtistDetailState.Ready(artistName, albums, allTracks)
165 }
166 }
167
168 fun loadGenre(genreName: String) {
169 _genreState.value = GenreState.Loading
170 viewModelScope.launch {
171 val tracks = scanner.tracksByGenre(genreName)
172 val albums = scanner.albumsByGenre(genreName)
173 _genreState.value = GenreState.Ready(tracks, albums)
174 }
175 }
176
177 /** Suspend function for direct use from composables (e.g. LocalRandomAlbumsScreen). */
178 suspend fun getRandomAlbums(count: Int): List<LocalMediaScanner.LocalAlbum> {
179 return scanner.randomAlbums(count)
180 }
181
182 // ── Playback dispatch ────────────────────────────────────────────────
183
184 /** Replace the queue with [tracks] starting at [startIndex] and play. */
185 fun playTracks(tracks: List<LocalMediaScanner.LocalTrack>, startIndex: Int = 0, onPlayed: (() -> Unit)? = null) {
186 if (tracks.isEmpty()) return
187 playGate.request(onPlayed) { renderer.load(tracks.map { it.toDidlTrack() }, startIndex) }
188 }
189
190 /** Shuffle the given track list in place (random order) and play from the start. */
191 fun shuffleTracks(tracks: List<LocalMediaScanner.LocalTrack>, onPlayed: (() -> Unit)? = null) {
192 if (tracks.isEmpty()) return
193 playGate.request(onPlayed) {
194 val shuffled = tracks.shuffled()
195 renderer.load(shuffled.map { it.toDidlTrack() }, 0)
196 renderer.setShuffle(true)
197 }
198 }
199
200 /** Append [tracks] to the queue. */
201 fun enqueueTracks(tracks: List<LocalMediaScanner.LocalTrack>) {
202 if (tracks.isEmpty()) return
203 viewModelScope.launch { renderer.enqueue(tracks.map { it.toDidlTrack() }) }
204 }
205
206 /** Insert [tracks] immediately after the current track. */
207 fun playNext(tracks: List<LocalMediaScanner.LocalTrack>) {
208 if (tracks.isEmpty()) return
209 playGate.request { renderer.playNext(tracks.map { it.toDidlTrack() }) }
210 }
211
212 /** Library-wide shuffle — load every track on the device in random order. */
213 fun shuffleAll() {
214 playGate.request {
215 val all = scanner.allTracks()
216 if (all.isNotEmpty()) {
217 val shuffled = all.shuffled()
218 renderer.load(shuffled.map { it.toDidlTrack() }, 0)
219 renderer.setShuffle(true)
220 }
221 }
222 }
223
224 // ── State sealed classes ─────────────────────────────────────────────
225
226 sealed class HubState {
227 data object PermissionRequired : HubState()
228 data object Loading : HubState()
229 data class Ready(val albumCount: Int, val artistCount: Int, val songCount: Int) : HubState()
230 }
231
232 sealed class AlbumsState {
233 data object PermissionRequired : AlbumsState()
234 data object Loading : AlbumsState()
235 data class Ready(val albums: List<LocalMediaScanner.LocalAlbum>) : AlbumsState()
236 }
237
238 sealed class ArtistsState {
239 data object PermissionRequired : ArtistsState()
240 data object Loading : ArtistsState()
241 data class Ready(val artists: List<Pair<Long, String>>) : ArtistsState()
242 }
243
244 sealed class SongsState {
245 data object PermissionRequired : SongsState()
246 data object Loading : SongsState()
247 data class Ready(val tracks: List<LocalMediaScanner.LocalTrack>) : SongsState()
248 }
249
250 sealed class AlbumDetailState {
251 data object PermissionRequired : AlbumDetailState()
252 data object Loading : AlbumDetailState()
253 data class Ready(
254 val album: LocalMediaScanner.LocalAlbum?,
255 val tracks: List<LocalMediaScanner.LocalTrack>,
256 ) : AlbumDetailState()
257 }
258
259 sealed class ArtistDetailState {
260 data object PermissionRequired : ArtistDetailState()
261 data object Loading : ArtistDetailState()
262 data class Ready(
263 val artistName: String,
264 val albums: List<LocalMediaScanner.LocalAlbum>,
265 val allTracks: List<LocalMediaScanner.LocalTrack>,
266 ) : ArtistDetailState()
267 }
268
269 sealed class GenreState {
270 data object Loading : GenreState()
271 data class Ready(
272 val tracks: List<LocalMediaScanner.LocalTrack>,
273 val albums: List<LocalMediaScanner.LocalAlbum>,
274 ) : GenreState()
275 }
276}