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()
}
}