LocalLibraryViewModel.kt
Each line, with the release that last changed it.
| 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 | } |


