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
TheLocalPlaylistRepository 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
- Mutex locking prevents concurrent file access
- StateFlow provides thread-safe state updates
- Dispatchers.IO for all file operations
Data Integrity
- UUID generation ensures unique playlist IDs
- Duplicate detection when adding tracks
- Automatic cleanup when tracks are deleted
- JSON serialization for portable storage
User Experience
- Favorites always first for quick access
- Track counts displayed for each playlist
- Context menus for playlist actions
- Confirmation dialogs before deletion
- Instant updates via StateFlow collection