Skip to main content

Overview

Deeztracker Mobile provides a comprehensive playlist system that allows you to create custom playlists, add tracks, and organize your local music library. Playlists are stored locally in JSON format and integrate seamlessly with the music player.

LocalPlaylistRepository

The LocalPlaylistRepository manages all playlist operations with thread-safe file I/O:
class LocalPlaylistRepository(private val context: Context) {
    private val playlistsFile = File(context.filesDir, "playlists.json")
    private val mutex = Mutex()
    
    private val _playlists = MutableStateFlow<List<LocalPlaylist>>(emptyList())
    val playlists: StateFlow<List<LocalPlaylist>> = _playlists
}

LocalPlaylist Model

data class LocalPlaylist(
    val id: String = UUID.randomUUID().toString(),
    val name: String,
    val trackIds: List<Long> = emptyList()
)
Playlists store track IDs that reference Android MediaStore entries, ensuring compatibility with local music.

Creating Playlists

Create New Playlist

suspend fun createPlaylist(name: String): String = withContext(Dispatchers.IO) {
    mutex.withLock {
        val newPlaylist = LocalPlaylist(name = name)
        val current = _playlists.value.toMutableList()
        current.add(newPlaylist)
        savePlaylistsToFile(current)
        _playlists.value = current
        newPlaylist.id
    }
}

UI Implementation

@Composable
fun CreatePlaylistDialog(
    onDismiss: () -> Unit,
    onCreate: (String) -> Unit
) {
    var playlistName by remember { mutableStateOf("") }
    
    AlertDialog(
        onDismissRequest = onDismiss,
        title = { Text(stringResource(R.string.new_playlist_title)) },
        text = {
            OutlinedTextField(
                value = playlistName,
                onValueChange = { playlistName = it },
                label = { Text(stringResource(R.string.new_playlist_name)) },
                singleLine = true
            )
        },
        confirmButton = {
            Button(
                onClick = {
                    if (playlistName.isNotBlank()) {
                        onCreate(playlistName)
                    }
                },
                colors = ButtonDefaults.buttonColors(containerColor = Primary)
            ) {
                Text(stringResource(R.string.action_create))
            }
        },
        dismissButton = {
            TextButton(onClick = onDismiss) {
                Text(stringResource(R.string.action_cancel))
            }
        }
    )
}

Adding Tracks to Playlists

Add Track

suspend fun addTrackToPlaylist(playlistId: String, trackId: Long) = withContext(Dispatchers.IO) {
    mutex.withLock {
        val current = _playlists.value.map { playlist ->
            if (playlist.id == playlistId) {
                if (!playlist.trackIds.contains(trackId)) {
                    playlist.copy(trackIds = playlist.trackIds + trackId)
                } else playlist
            } else playlist
        }
        if (current != _playlists.value) {
            savePlaylistsToFile(current)
            _playlists.value = current
        }
    }
}

Bottom Sheet UI

@Composable
fun AddToPlaylistBottomSheet(
    playlists: List<LocalPlaylist>,
    onDismiss: () -> Unit,
    onPlaylistClick: (LocalPlaylist) -> Unit,
    onCreateNewPlaylist: () -> Unit
) {
    ModalBottomSheet(onDismissRequest = onDismiss) {
        Column {
            Text(
                text = stringResource(R.string.add_to_playlist_title),
                fontSize = 20.sp,
                fontWeight = FontWeight.Bold,
                modifier = Modifier.padding(16.dp)
            )
            
            LazyColumn {
                items(playlists) { playlist ->
                    Row(
                        modifier = Modifier
                            .fillMaxWidth()
                            .clickable { onPlaylistClick(playlist) }
                            .padding(16.dp)
                    ) {
                        Icon(Icons.Default.MusicNote, contentDescription = null)
                        Spacer(modifier = Modifier.width(16.dp))
                        Column {
                            Text(text = playlist.name, fontWeight = FontWeight.Bold)
                            Text(
                                text = "${playlist.trackIds.size} tracks",
                                color = TextGray
                            )
                        }
                    }
                }
                
                item {
                    Row(
                        modifier = Modifier
                            .fillMaxWidth()
                            .clickable(onClick = onCreateNewPlaylist)
                            .padding(16.dp)
                    ) {
                        Icon(Icons.Default.Add, tint = Primary)
                        Spacer(modifier = Modifier.width(16.dp))
                        Text(
                            text = stringResource(R.string.new_playlist_title),
                            color = Primary,
                            fontWeight = FontWeight.Bold
                        )
                    }
                }
            }
        }
    }
}
The bottom sheet allows quick playlist selection or creating a new playlist on the fly.

Removing Tracks

Remove from Playlist

suspend fun removeTrackFromPlaylist(playlistId: String, trackId: Long) = withContext(Dispatchers.IO) {
    mutex.withLock {
        val current = _playlists.value.map { playlist ->
            if (playlist.id == playlistId) {
                playlist.copy(trackIds = playlist.trackIds - trackId)
            } else playlist
        }
        savePlaylistsToFile(current)
        _playlists.value = current
    }
}

Remove from All Playlists

Useful when deleting a track from the device:
suspend fun removeTrackFromAllPlaylists(trackId: Long) = withContext(Dispatchers.IO) {
    mutex.withLock {
        val current = _playlists.value.map { playlist ->
            if (playlist.trackIds.contains(trackId)) {
                playlist.copy(trackIds = playlist.trackIds - trackId)
            } else playlist
        }
        if (current != _playlists.value) {
            savePlaylistsToFile(current)
            _playlists.value = current
        }
    }
}

Favorites Playlist

Deeztracker automatically creates and manages a special “Favorites” playlist:

Initialization

suspend fun loadPlaylists() = withContext(Dispatchers.IO) {
    if (!playlistsFile.exists()) {
        // Create default Favorites playlist
        val initialPlaylists = listOf(LocalPlaylist(id = "favorites", name = "Favorites"))
        savePlaylistsToFile(initialPlaylists)
        _playlists.value = initialPlaylists
        return@withContext
    }
    
    val loadedPlaylists = /* load from JSON */
    
    // Ensure Favorites exists
    if (loadedPlaylists.none { it.id == "favorites" }) {
        loadedPlaylists.add(0, LocalPlaylist(id = "favorites", name = "Favorites"))
        savePlaylistsToFile(loadedPlaylists)
    }
    
    _playlists.value = loadedPlaylists
}

Toggle Favorite

fun isFavorite(trackId: Long): Boolean {
    return _playlists.value
        .find { it.id == "favorites" }
        ?.trackIds
        ?.contains(trackId) == true
}

suspend fun toggleFavorite(trackId: Long) {
    val favorites = _playlists.value.find { it.id == "favorites" } ?: return
    if (favorites.trackIds.contains(trackId)) {
        removeTrackFromPlaylist("favorites", trackId)
    } else {
        addTrackToPlaylist("favorites", trackId)
    }
}

UI Integration

IconButton(onClick = { playerController.toggleFavorite() }) {
    Icon(
        if (playerState.isCurrentTrackFavorite)
            Icons.Default.Favorite
        else
            Icons.Default.FavoriteBorder,
        tint = Primary
    )
}
The Favorites playlist has a fixed ID (“favorites”) and cannot be deleted or renamed.

Playlist Management

Rename Playlist

suspend fun renamePlaylist(playlistId: String, newName: String) = withContext(Dispatchers.IO) {
    mutex.withLock {
        val current = _playlists.value.map { playlist ->
            if (playlist.id == playlistId) {
                playlist.copy(name = newName)
            } else playlist
        }
        savePlaylistsToFile(current)
        _playlists.value = current
    }
}

Delete Playlist

suspend fun deletePlaylist(playlistId: String) = withContext(Dispatchers.IO) {
    mutex.withLock {
        val current = _playlists.value.filter { it.id != playlistId }
        savePlaylistsToFile(current)
        _playlists.value = current
    }
}

Edit Dialog

@Composable
fun EditPlaylistDialog(
    currentName: String,
    onDismiss: () -> Unit,
    onEdit: (String) -> Unit
) {
    var newName by remember { mutableStateOf(currentName) }
    
    AlertDialog(
        onDismissRequest = onDismiss,
        title = { Text(stringResource(R.string.edit_playlist_title)) },
        text = {
            OutlinedTextField(
                value = newName,
                onValueChange = { newName = it },
                label = { Text(stringResource(R.string.playlist_name_label)) },
                singleLine = true
            )
        },
        confirmButton = {
            Button(onClick = { onEdit(newName) }) {
                Text(stringResource(R.string.action_save))
            }
        },
        dismissButton = {
            TextButton(onClick = onDismiss) {
                Text(stringResource(R.string.action_cancel))
            }
        }
    )
}

Importing from Deezer

While the repository doesn’t directly handle Deezer imports, the app can download entire playlists:
// In DownloadManager
fun startPlaylistDownload(playlistId: Long, title: String): Boolean {
    downloadChannel.trySend(DownloadRequest.Playlist(playlistId, title))
    return true
}

// Optionally create local playlist and add tracks
private suspend fun processPlaylistDownload(request: DownloadRequest.Playlist) {
    val deezerTracks = deezerRepository.getPlaylistTracks(request.id).data
    
    // Create local playlist
    val localPlaylistId = playlistRepository.createPlaylist(request.title)
    
    for (track in deezerTracks) {
        try {
            val result = rustService.downloadTrack(...)
            scanFileSuspend(result.path)
            
            // Get MediaStore ID and add to playlist
            val trackId = musicRepository.getTrackIdByPath(result.path)
            if (trackId != null) {
                playlistRepository.addTrackToPlaylist(localPlaylistId, trackId)
            }
        } catch (e: Exception) {
            Log.e(TAG, "Failed to download track", e)
        }
    }
}
Imported playlists are converted to local playlists with tracks stored in the device’s music folder.

Playlist Screen UI

Playlist List

@Composable
fun LocalPlaylistsScreen(
    playlists: List<LocalPlaylist>,
    onPlaylistClick: (LocalPlaylist) -> Unit,
    onDeletePlaylist: (LocalPlaylist) -> Unit,
    onEditPlaylist: (LocalPlaylist, String) -> Unit,
    onCreatePlaylist: () -> Unit
) {
    LazyColumn {
        // Stats
        item {
            Text(
                text = stringResource(R.string.stats_playlists_format, playlists.size),
                color = TextGray
            )
        }
        
        // Create button
        item {
            Column(
                modifier = Modifier.clickable(onClick = onCreatePlaylist),
                horizontalAlignment = Alignment.CenterHorizontally
            ) {
                Box(
                    modifier = Modifier
                        .size(80.dp)
                        .background(Primary.copy(alpha = 0.2f)),
                    contentAlignment = Alignment.Center
                ) {
                    Icon(Icons.Default.Add, tint = Primary)
                }
                Text(stringResource(R.string.new_playlist_title))
            }
        }
        
        // Playlist items
        items(playlists) { playlist ->
            var showMenu by remember { mutableStateOf(false) }
            
            Row(
                modifier = Modifier
                    .fillMaxWidth()
                    .clickable { onPlaylistClick(playlist) }
            ) {
                Column(modifier = Modifier.weight(1f)) {
                    Text(text = playlist.name, fontWeight = FontWeight.Bold)
                    Text(
                        text = "${playlist.trackIds.size} tracks",
                        color = TextGray
                    )
                }
                
                // Context menu (not for favorites)
                if (playlist.id != "favorites") {
                    Box {
                        IconButton(onClick = { showMenu = true }) {
                            Icon(Icons.Default.MoreVert)
                        }
                        DropdownMenu(
                            expanded = showMenu,
                            onDismissRequest = { showMenu = false }
                        ) {
                            DropdownMenuItem(
                                text = { Text("Edit") },
                                onClick = { /* show edit dialog */ }
                            )
                            DropdownMenuItem(
                                text = { Text("Delete", color = Color.Red) },
                                onClick = { onDeletePlaylist(playlist) }
                            )
                        }
                    }
                }
            }
        }
    }
}

File Storage

JSON Format

[
  {
    "id": "favorites",
    "name": "Favorites",
    "trackIds": [1, 5, 12, 43]
  },
  {
    "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
    "name": "My Workout Mix",
    "trackIds": [7, 23, 45]
  }
]

Save to File

private fun savePlaylistsToFile(playlists: List<LocalPlaylist>) {
    try {
        val jsonArray = JSONArray()
        playlists.forEach { playlist ->
            val obj = JSONObject()
            obj.put("id", playlist.id)
            obj.put("name", playlist.name)
            val tracksArray = JSONArray()
            playlist.trackIds.forEach { tracksArray.put(it) }
            obj.put("trackIds", tracksArray)
            jsonArray.put(obj)
        }
        playlistsFile.writeText(jsonArray.toString())
    } catch (e: Exception) {
        e.printStackTrace()
    }
}

Load from File

suspend fun loadPlaylists() = withContext(Dispatchers.IO) {
    if (!playlistsFile.exists()) { /* create default */ }
    
    try {
        val jsonString = playlistsFile.readText()
        val jsonArray = JSONArray(jsonString)
        val loadedPlaylists = mutableListOf<LocalPlaylist>()
        
        for (i in 0 until jsonArray.length()) {
            val obj = jsonArray.getJSONObject(i)
            val id = obj.getString("id")
            val name = obj.getString("name")
            val tracksJson = obj.getJSONArray("trackIds")
            val trackIds = mutableListOf<Long>()
            for (j in 0 until tracksJson.length()) {
                trackIds.add(tracksJson.getLong(j))
            }
            loadedPlaylists.add(LocalPlaylist(id, name, trackIds))
        }
        
        _playlists.value = loadedPlaylists
    } catch (e: Exception) {
        e.printStackTrace()
        _playlists.value = emptyList()
    }
}
Always use a mutex when reading or writing playlists to prevent race conditions.

Best Practices

Concurrency

  1. Mutex locking prevents concurrent file access
  2. StateFlow provides thread-safe state updates
  3. Dispatchers.IO for all file operations

Data Integrity

  1. UUID generation ensures unique playlist IDs
  2. Duplicate detection when adding tracks
  3. Automatic cleanup when tracks are deleted
  4. JSON serialization for portable storage

User Experience

  1. Favorites always first for quick access
  2. Track counts displayed for each playlist
  3. Context menus for playlist actions
  4. Confirmation dialogs before deletion
  5. Instant updates via StateFlow collection

Build docs developers (and LLMs) love