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

LocalLibraryViewModel.kt

package dev.stave.ui.local

import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import android.os.Build
import androidx.core.content.ContextCompat
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import dev.stave.domain.renderer.Renderer
import dev.stave.local.LocalMediaScanner
import dev.stave.local.toDidlTrack
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import javax.inject.Inject

/**
 * Drives the on-device music browsing screens (LocalHub / LocalAlbums / LocalAlbum / etc.)
 * and is the single dispatcher for "play these local tracks now" / "queue these"
 * / "shuffle these" operations.
 *
 * Permission model: API 33+ requires READ_MEDIA_AUDIO at runtime; older API 29-32 accept
 * READ_EXTERNAL_STORAGE. The relevant permission is exposed via [permissionName] so the UI
 * can call [androidx.activity.result.contract.ActivityResultContracts.RequestPermission]
 * with the right string. The screen calls [refreshPermissionState] after the user responds.
 *
 * Injection note: uses @ApplicationContext (not Application) because Hilt's @HiltViewModel
 * binds Context, not Application. Trying to constructor-inject Application here causes
 * Hilt to fail at ViewModel creation — i.e. the screen would crash on first navigation
 * with "MissingBinding for android.app.Application".
 */
@HiltViewModel
class LocalLibraryViewModel @Inject constructor(
    private val playGate: dev.stave.playback.LocalPlaybackGate,
    @ApplicationContext private val context: Context,
    private val scanner: LocalMediaScanner,
    private val renderer: Renderer,
) : ViewModel() {

    private val _hubState = MutableStateFlow<HubState>(HubState.PermissionRequired)
    val hubState: StateFlow<HubState> = _hubState.asStateFlow()

    private val _albumsState = MutableStateFlow<AlbumsState>(AlbumsState.Loading)
    val albumsState: StateFlow<AlbumsState> = _albumsState.asStateFlow()

    private val _artistsState = MutableStateFlow<ArtistsState>(ArtistsState.Loading)
    val artistsState: StateFlow<ArtistsState> = _artistsState.asStateFlow()

    private val _songsState = MutableStateFlow<SongsState>(SongsState.Loading)
    val songsState: StateFlow<SongsState> = _songsState.asStateFlow()

    private val _albumDetailState = MutableStateFlow<AlbumDetailState>(AlbumDetailState.Loading)
    val albumDetailState: StateFlow<AlbumDetailState> = _albumDetailState.asStateFlow()

    private val _artistDetailState = MutableStateFlow<ArtistDetailState>(ArtistDetailState.Loading)
    val artistDetailState: StateFlow<ArtistDetailState> = _artistDetailState.asStateFlow()

    private val _genreState = MutableStateFlow<GenreState>(GenreState.Loading)
    val genreState: StateFlow<GenreState> = _genreState.asStateFlow()

    /** Returns the runtime permission to request for media access on this Android version.
     *
     *  MUST BE DECLARED BEFORE THE init BLOCK BELOW. Kotlin initializes properties in
     *  source order; if init runs first it calls hasPermission() which reads this value
     *  before it has been assigned, throwing NPE "permission must be non-null" inside
     *  ContextCompat.checkSelfPermission. Symptom: every navigation to LocalHubScreen
     *  crashes the process at composable construction time. */
    val permissionName: String =
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) Manifest.permission.READ_MEDIA_AUDIO
        else Manifest.permission.READ_EXTERNAL_STORAGE

    init {
        refreshPermissionState()
    }

    fun hasPermission(): Boolean =
        ContextCompat.checkSelfPermission(context, permissionName) == PackageManager.PERMISSION_GRANTED

    /** Call this in onResume of LocalHubScreen and after PermissionResult callback so
     *  the hub state reflects the current permission. */
    fun refreshPermissionState() {
        if (!hasPermission()) {
            _hubState.value = HubState.PermissionRequired
            return
        }
        // Permission granted — drop any stale cached scan from a prior pre-grant
        // attempt (which would have returned 0 tracks) and load summary counts.
        scanner.invalidateCache()
        _hubState.value = HubState.Loading
        viewModelScope.launch {
            try {
                val albums = scanner.scanAlbums()
                val artists = scanner.scanArtists()
                val songs = scanner.allTracks()
                _hubState.value = HubState.Ready(
                    albumCount = albums.size,
                    artistCount = artists.size,
                    songCount = songs.size,
                )
            } catch (t: Throwable) {
                // Don't let a scan failure kill the process. Show 0/0/0 so the
                // hub still renders its empty-state UI.
                android.util.Log.e("EarhornLocal", "Hub scan failed", t)
                _hubState.value = HubState.Ready(0, 0, 0)
            }
        }
    }

    fun loadAlbums() {
        if (!hasPermission()) {
            _albumsState.value = AlbumsState.PermissionRequired; return
        }
        _albumsState.value = AlbumsState.Loading
        viewModelScope.launch {
            _albumsState.value = AlbumsState.Ready(scanner.scanAlbums())
        }
    }

    fun loadArtists() {
        if (!hasPermission()) {
            _artistsState.value = ArtistsState.PermissionRequired; return
        }
        _artistsState.value = ArtistsState.Loading
        viewModelScope.launch {
            _artistsState.value = ArtistsState.Ready(scanner.scanArtists())
        }
    }

    fun loadSongs() {
        if (!hasPermission()) {
            _songsState.value = SongsState.PermissionRequired; return
        }
        _songsState.value = SongsState.Loading
        viewModelScope.launch {
            _songsState.value = SongsState.Ready(scanner.allTracks())
        }
    }

    fun loadAlbum(albumId: Long) {
        if (!hasPermission()) {
            _albumDetailState.value = AlbumDetailState.PermissionRequired; return
        }
        _albumDetailState.value = AlbumDetailState.Loading
        viewModelScope.launch {
            val tracks = scanner.tracksForAlbum(albumId)
            // Pull the album header info from scanAlbums (small list, reasonable to scan again).
            val album = scanner.scanAlbums().firstOrNull { it.id == albumId }
            _albumDetailState.value = AlbumDetailState.Ready(album, tracks)
        }
    }

    fun loadArtist(artistName: String) {
        if (!hasPermission()) {
            _artistDetailState.value = ArtistDetailState.PermissionRequired; return
        }
        _artistDetailState.value = ArtistDetailState.Loading
        viewModelScope.launch {
            val albums = scanner.albumsForArtist(artistName)
            val allTracks = scanner.tracksForArtist(artistName)
            _artistDetailState.value = ArtistDetailState.Ready(artistName, albums, allTracks)
        }
    }

    fun loadGenre(genreName: String) {
        _genreState.value = GenreState.Loading
        viewModelScope.launch {
            val tracks = scanner.tracksByGenre(genreName)
            val albums = scanner.albumsByGenre(genreName)
            _genreState.value = GenreState.Ready(tracks, albums)
        }
    }

    /** Suspend function for direct use from composables (e.g. LocalRandomAlbumsScreen). */
    suspend fun getRandomAlbums(count: Int): List<LocalMediaScanner.LocalAlbum> {
        return scanner.randomAlbums(count)
    }

    // ── Playback dispatch ────────────────────────────────────────────────

    /** Replace the queue with [tracks] starting at [startIndex] and play. */
    fun playTracks(tracks: List<LocalMediaScanner.LocalTrack>, startIndex: Int = 0, onPlayed: (() -> Unit)? = null) {
        if (tracks.isEmpty()) return
        playGate.request(onPlayed) { renderer.load(tracks.map { it.toDidlTrack() }, startIndex) }
    }

    /** Shuffle the given track list in place (random order) and play from the start. */
    fun shuffleTracks(tracks: List<LocalMediaScanner.LocalTrack>, onPlayed: (() -> Unit)? = null) {
        if (tracks.isEmpty()) return
        playGate.request(onPlayed) {
            val shuffled = tracks.shuffled()
            renderer.load(shuffled.map { it.toDidlTrack() }, 0)
            renderer.setShuffle(true)
        }
    }

    /** Append [tracks] to the queue. */
    fun enqueueTracks(tracks: List<LocalMediaScanner.LocalTrack>) {
        if (tracks.isEmpty()) return
        viewModelScope.launch { renderer.enqueue(tracks.map { it.toDidlTrack() }) }
    }

    /** Insert [tracks] immediately after the current track. */
    fun playNext(tracks: List<LocalMediaScanner.LocalTrack>) {
        if (tracks.isEmpty()) return
        playGate.request { renderer.playNext(tracks.map { it.toDidlTrack() }) }
    }

    /** Library-wide shuffle — load every track on the device in random order. */
    fun shuffleAll() {
        playGate.request {
            val all = scanner.allTracks()
            if (all.isNotEmpty()) {
                val shuffled = all.shuffled()
                renderer.load(shuffled.map { it.toDidlTrack() }, 0)
                renderer.setShuffle(true)
            }
        }
    }

    // ── State sealed classes ─────────────────────────────────────────────

    sealed class HubState {
        data object PermissionRequired : HubState()
        data object Loading : HubState()
        data class Ready(val albumCount: Int, val artistCount: Int, val songCount: Int) : HubState()
    }

    sealed class AlbumsState {
        data object PermissionRequired : AlbumsState()
        data object Loading : AlbumsState()
        data class Ready(val albums: List<LocalMediaScanner.LocalAlbum>) : AlbumsState()
    }

    sealed class ArtistsState {
        data object PermissionRequired : ArtistsState()
        data object Loading : ArtistsState()
        data class Ready(val artists: List<Pair<Long, String>>) : ArtistsState()
    }

    sealed class SongsState {
        data object PermissionRequired : SongsState()
        data object Loading : SongsState()
        data class Ready(val tracks: List<LocalMediaScanner.LocalTrack>) : SongsState()
    }

    sealed class AlbumDetailState {
        data object PermissionRequired : AlbumDetailState()
        data object Loading : AlbumDetailState()
        data class Ready(
            val album: LocalMediaScanner.LocalAlbum?,
            val tracks: List<LocalMediaScanner.LocalTrack>,
        ) : AlbumDetailState()
    }

    sealed class ArtistDetailState {
        data object PermissionRequired : ArtistDetailState()
        data object Loading : ArtistDetailState()
        data class Ready(
            val artistName: String,
            val albums: List<LocalMediaScanner.LocalAlbum>,
            val allTracks: List<LocalMediaScanner.LocalTrack>,
        ) : ArtistDetailState()
    }

    sealed class GenreState {
        data object Loading : GenreState()
        data class Ready(
            val tracks: List<LocalMediaScanner.LocalTrack>,
            val albums: List<LocalMediaScanner.LocalAlbum>,
        ) : GenreState()
    }
}