LocalLibraryViewModel.kt
276 lines · 11.6 KB
Changed in: 0.5.0
| 1 | package dev.stave.ui.local |
| 2 | |
| 3 | import android.Manifest |
| 4 | import android.content.Context |
| 5 | import android.content.pm.PackageManager |
| 6 | import android.os.Build |
| 7 | import androidx.core.content.ContextCompat |
| 8 | import androidx.lifecycle.ViewModel |
| 9 | import androidx.lifecycle.viewModelScope |
| 10 | import dagger.hilt.android.lifecycle.HiltViewModel |
| 11 | import dagger.hilt.android.qualifiers.ApplicationContext |
| 12 | import dev.stave.domain.renderer.Renderer |
| 13 | import dev.stave.local.LocalMediaScanner |
| 14 | import dev.stave.local.toDidlTrack |
| 15 | import kotlinx.coroutines.flow.MutableStateFlow |
| 16 | import kotlinx.coroutines.flow.StateFlow |
| 17 | import kotlinx.coroutines.flow.asStateFlow |
| 18 | import kotlinx.coroutines.launch |
| 19 | import 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 |
| 37 | class 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 | } |


