Pith - jamb
jamb/app/src/main/java/com/vgmlr/jamb/JambViewModel.kt [12.4 kb]
Modified: 20:33:38 112 026 (09 Jul 026)
16 Days Ago
package com.vgmlr.jamb

import android.app.Application
import android.widget.Toast
import androidx.core.net.toUri
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import androidx.media3.common.MediaItem
import androidx.media3.common.MediaMetadata
import androidx.media3.common.Player
import androidx.media3.session.MediaController
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext

class JambViewModel(app: Application) : AndroidViewModel(app) {

    private val repo = JambMediaStore(app)
    private val prefs = JambPrefs(app)
    private val playlistRepo = JambPlaylists(app)

    private var controller: MediaController? = null
    private var listener: Player.Listener? = null
    private var hasPermission = false
    private var loaded = false
    private var contentReady = false
    private var pendingUri: String? = null
    private var master: List<JambTrack> = emptyList()

    private val _tracks = MutableStateFlow<List<JambTrack>>(emptyList()); val tracks = _tracks.asStateFlow()
    private val _folders = MutableStateFlow<List<String>>(emptyList()); val folders = _folders.asStateFlow()
    private val _folder = MutableStateFlow(JambPrefs.ALL); val folder = _folder.asStateFlow()
    private val _mode = MutableStateFlow(JambPrefs.MODE_SORTED); val mode = _mode.asStateFlow()
    private val _repeat = MutableStateFlow(false); val repeat = _repeat.asStateFlow()
    private val _index = MutableStateFlow(0); val currentIndex = _index.asStateFlow()
    private val _isPlaying = MutableStateFlow(false); val isPlaying = _isPlaying.asStateFlow()
    private val _position = MutableStateFlow(0L); val position = _position.asStateFlow()
    private val _selected = MutableStateFlow<JambTrack?>(null); val selected = _selected.asStateFlow()
    private val _playlists = MutableStateFlow<List<String>>(emptyList()); val playlists = _playlists.asStateFlow()
    private val _currentPlaylist = MutableStateFlow<String?>(null); val currentPlaylist = _currentPlaylist.asStateFlow()
    private val _playlistItems = MutableStateFlow<Set<String>>(emptySet()); val playlistItems = _playlistItems.asStateFlow()

    fun setController(c: MediaController) {
        controller = c
        val l = object : Player.Listener {
            override fun onIsPlayingChanged(playing: Boolean) { _isPlaying.value = playing }
            override fun onMediaItemTransition(item: MediaItem?, reason: Int) { syncIndex() }
            override fun onPositionDiscontinuity(
                old: Player.PositionInfo, new: Player.PositionInfo, reason: Int
            ) { _position.value = controller?.currentPosition ?: 0L }
            override fun onPlaybackStateChanged(state: Int) { syncIndex() }
        }
        listener = l
        c.addListener(l)
        _isPlaying.value = c.isPlaying
        startTicker()
        tryLoad()
    }

    fun setPermission(granted: Boolean) {
        hasPermission = granted
        tryLoad()
    }

    fun clearController() {
        listener?.let { controller?.removeListener(it) }
        controller?.release()
        controller = null
        listener = null
    }

    private fun startTicker() {
        viewModelScope.launch {
            while (isActive) {
                controller?.let { if (it.isPlaying) _position.value = it.currentPosition }
                delay(500)
            }
        }
    }

    fun rescan() {
        loaded = false
        tryLoad()
    }

    private fun tryLoad() {
        if (controller == null || !hasPermission || loaded) return
        loaded = true
        contentReady = false
        viewModelScope.launch {
            val (pf, pm, pr) = prefs.read()
            val pl = prefs.getPlaylist()
            master = withContext(Dispatchers.IO) { repo.queryTracks() }
            refreshFolders()
            _playlists.value = playlistRepo.names()
            _mode.value = pm
            _repeat.value = pr
            if (pl != null && _playlists.value.contains(pl)) {
                _currentPlaylist.value = pl
                _playlistItems.value = playlistRepo.items(pl).toSet()
                _folder.value = JambPrefs.NONE
            } else {
                _folder.value = if (pf == JambPrefs.ALL || _folders.value.contains(pf)) pf else JambPrefs.ALL
            }
            controller?.repeatMode = if (pr) Player.REPEAT_MODE_ALL else Player.REPEAT_MODE_OFF
            rebuild(preserve = false)
            contentReady = true
            consumePendingUri()
        }
    }

    fun setPendingUri(uri: String) {
        pendingUri = uri
        if (contentReady) consumePendingUri()
    }

    private fun consumePendingUri() {
        if (controller == null) return
        val uriStr = pendingUri ?: return
        pendingUri = null
        val uri = uriStr.toUri()

        viewModelScope.launch {
            var track = repo.matchTrack(uri, master)

            if (track == null) {
                master = withContext(Dispatchers.IO) { repo.queryTracks() }
                refreshFolders()
                track = repo.matchTrack(uri, master)
            }

            if (track == null) {
                val scanned = withContext(Dispatchers.IO) { repo.forceScan(uri) }
                if (scanned != null) {
                    master = withContext(Dispatchers.IO) { repo.queryTracks() }
                    refreshFolders()
                    track = repo.matchTrack(scanned, master) ?: repo.matchTrack(uri, master)
                }
            }

            val resolved = track
            if (resolved == null) {
                Toast.makeText(
                    getApplication(), "File Not Found In Library", Toast.LENGTH_SHORT
                ).show()
                return@launch
            }
            playTrack(resolved)
        }
    }

    private fun playTrack(track: JambTrack) {
        val c = controller ?: return
        _folder.value = track.folder
        viewModelScope.launch { prefs.setFolder(track.folder) }
        rebuild(preserve = false)
        val idx = _tracks.value.indexOfFirst { it.id == track.id }
        if (idx >= 0) {
            c.seekTo(idx, 0)
            c.play()
        }
    }

    private fun refreshFolders() {
        _folders.value = master.map { it.folder }.distinct().sortedBy { it.lowercase() }
    }

    private fun ordered(): List<JambTrack> {
        val filtered = when {
            _currentPlaylist.value != null ->
                master.filter { _playlistItems.value.contains(it.key) }
            _folder.value == JambPrefs.ALL -> master
            else -> master.filter { it.folder == _folder.value }
        }
        return if (_mode.value == JambPrefs.MODE_SHUFFLE) filtered.shuffled()
        else filtered.sortedBy { it.displayName.lowercase() }
    }

    private fun rebuild(preserve: Boolean) {
        val c = controller ?: return
        val list = ordered()
        _tracks.value = list

        if (list.isEmpty()) {
            c.clearMediaItems()
            _selected.value = null
            _index.value = 0
            return
        }

        val wasPlaying = preserve && c.isPlaying
        val currentId = if (preserve) c.currentMediaItem?.mediaId else null
        val currentPos = if (preserve) c.currentPosition else 0L

        var startIndex = 0
        if (currentId != null) {
            val i = list.indexOfFirst { it.id.toString() == currentId }
            if (i >= 0) startIndex = i
        }
        val samePreserved =
            currentId != null && list.getOrNull(startIndex)?.id?.toString() == currentId
        val startPos = if (samePreserved) currentPos else 0L

        c.setMediaItems(list.map { mediaItem(it) }, startIndex, startPos)
        c.prepare()
        c.playWhenReady = wasPlaying
        _index.value = startIndex
        _selected.value = list.getOrNull(startIndex)
    }

    private fun mediaItem(t: JambTrack) = MediaItem.Builder()
        .setMediaId(t.id.toString())
        .setUri(t.uri.toUri())
        .setMediaMetadata(MediaMetadata.Builder().setTitle(t.displayName).build())
        .build()

    private fun syncIndex() {
        val c = controller ?: return
        val idx = c.currentMediaItemIndex
        _index.value = idx
        _selected.value = _tracks.value.getOrNull(idx)
        _position.value = c.currentPosition
    }

    fun onTrackTap(i: Int) {
        val c = controller ?: return
        c.seekTo(i, 0)
        c.play()
    }

    fun playPause() {
        val c = controller ?: return
        if (c.isPlaying) c.pause() else c.play()
    }

    fun next() { controller?.seekToNext() }
    fun prev() { controller?.seekToPrevious() }

    fun seekTo(ms: Long) {
        controller?.seekTo(ms)
        _position.value = ms
    }

    fun toggleMode() {
        _mode.value =
            if (_mode.value == JambPrefs.MODE_SORTED) JambPrefs.MODE_SHUFFLE else JambPrefs.MODE_SORTED
        viewModelScope.launch { prefs.setMode(_mode.value) }
        rebuild(preserve = true)
    }

    fun toggleRepeat() {
        _repeat.value = !_repeat.value
        controller?.repeatMode = if (_repeat.value) Player.REPEAT_MODE_ALL else Player.REPEAT_MODE_OFF
        viewModelScope.launch { prefs.setRepeat(_repeat.value) }
    }

    fun setFolder(f: String) {
        _folder.value = f
        _currentPlaylist.value = null
        _playlistItems.value = emptySet()
        viewModelScope.launch {
            prefs.setFolder(f)
            prefs.setPlaylist(null)
        }
        rebuild(preserve = true)
    }

    fun defaultPlaylistName(): String {
        val existing = _playlists.value
        var n = existing.size + 1
        while (existing.contains("Play List $n")) n++
        return "Play List $n"
    }

    fun loadPlaylist(name: String) {
        _currentPlaylist.value = name
        _folder.value = JambPrefs.NONE
        viewModelScope.launch {
            _playlistItems.value = playlistRepo.items(name).toSet()
            prefs.setFolder(JambPrefs.NONE)
            prefs.setPlaylist(name)
            rebuild(preserve = false)
        }
    }

    fun newPlaylist(name: String, addFile: String?, load: Boolean) {
        viewModelScope.launch {
            playlistRepo.create(name)
            if (addFile != null) playlistRepo.add(name, addFile)
            _playlists.value = playlistRepo.names()
            if (load) {
                _currentPlaylist.value = name
                _folder.value = JambPrefs.NONE
                _playlistItems.value = playlistRepo.items(name).toSet()
                prefs.setFolder(JambPrefs.NONE)
                prefs.setPlaylist(name)
                rebuild(preserve = false)
            } else if (_currentPlaylist.value == name) {
                _playlistItems.value = playlistRepo.items(name).toSet()
                rebuild(preserve = true)
            }
        }
    }

    fun addToPlaylist(name: String, file: String) {
        viewModelScope.launch {
            playlistRepo.add(name, file)
            _playlists.value = playlistRepo.names()
            if (_currentPlaylist.value == name) {
                _playlistItems.value = playlistRepo.items(name).toSet()
                rebuild(preserve = true)
            }
        }
    }

    fun removeFromCurrentPlaylist(file: String) {
        val name = _currentPlaylist.value ?: return
        viewModelScope.launch {
            playlistRepo.remove(name, file)
            _playlistItems.value = playlistRepo.items(name).toSet()
            rebuild(preserve = true)
        }
    }

    fun deleteCurrentPlaylist() {
        val name = _currentPlaylist.value ?: return
        viewModelScope.launch {
            playlistRepo.delete(name)
            _playlists.value = playlistRepo.names()
            _currentPlaylist.value = null
            _playlistItems.value = emptySet()
            _folder.value = JambPrefs.ALL
            prefs.setFolder(JambPrefs.ALL)
            prefs.setPlaylist(null)
            rebuild(preserve = false)
        }
    }

    fun renameCurrentPlaylist(to: String) {
        val from = _currentPlaylist.value ?: return
        if (to == from) return
        viewModelScope.launch {
            playlistRepo.rename(from, to)
            _playlists.value = playlistRepo.names()
            _currentPlaylist.value = to
            _playlistItems.value = playlistRepo.items(to).toSet()
            prefs.setPlaylist(to)
            rebuild(preserve = true)
        }
    }

    override fun onCleared() {
        clearController()
        super.onCleared()
    }
}
Updates
Wedge - Android 126.026
Wedge - Linux 124.026
Shim - Android 117.026
Miter - 114.026
Kerf - Android 112.026
Dev
TVShow (227) 'CSA'
TVShow (228) 'APT'
TVProgram (83) 'BXT'
Miter Update(s)
Peen (Messaging)

Menu
Calendar
Project Tin (024/029)
Miter
RSS Feed
User Avatar
@vgmlr
=SUM(parts)