feat(library): add local playlist support

- Add LocalPlaylistRepository for managing local playlists as M3U8 files
- Add LocalPlaylistViewModel for local playlist UI state management
- Add LocalPlaylistCard component for displaying local playlists
- Update LibraryPlaylistsScreen to show both Spotify and local playlists
- Add string resources for local playlist section headers
- Register LocalPlaylistRepository and LocalPlaylistViewModel in DI

Local playlists are stored as M3U8 files in the Playlists directory,
can contain local tracks (from LocalMediaTrack) and are fully
integrated with the existing library UI.
This commit is contained in:
thumb2086 2026-10-31 04:12:07 +08:00
parent 10a8200a98
commit dbf63637ba
6 changed files with 836 additions and 29 deletions

View File

@ -45,6 +45,10 @@
<string name="library_action_list_view">Switch to list view</string>
<string name="library_playlists_empty">No saved playlists</string>
<string name="library_playlists_no_results">No playlists match “%1$s”</string>
<string name="library_local_playlists_empty">No local playlists yet</string>
<string name="library_playlist_name_empty">Playlist name cannot be empty</string>
<string name="library_playlist_name_exists">A playlist with this name already exists</string>
<string name="library_playlist_not_found">Playlist not found</string>
<string name="library_albums_empty">No saved albums</string>
<string name="library_albums_no_results">No albums match “%1$s”</string>
<string name="library_artists_empty">No saved artists</string>
@ -74,6 +78,9 @@
<string name="library_local_no_results">No matching folders or tracks</string>
<string name="library_local_refresh_failed">Failed to refresh local media</string>
<string name="library_playlists_spotify_section">Spotify Playlists</string>
<string name="library_local_playlists_section">Local Playlists</string>
<!-- Downloads -->
<string name="downloads_summary">%1$d active • %2$d completed</string>
<string name="downloads_saved_to">Saved to %1$s</string>

View File

@ -135,7 +135,15 @@ val sharedModules = module {
singleOf(::LocalMediaCacheRepository)
singleOf(::LocalMediaFoldersConfig)
singleOf(::LocalMediaLibraryCoordinator)
// Library
singleOf(::LibraryState)
singleOf(::LibraryRepository)
singleOf(::LocalMediaCacheRepository)
singleOf(::LocalMediaFoldersConfig)
singleOf(::LocalMediaLibraryCoordinator)
singleOf(::LocalPlaylistRepository) // Local playlist repository
viewModelOf(::LibraryPlaylistsViewModel)
viewModelOf(::LocalPlaylistViewModel) // Local playlist view model
viewModelOf(::LibraryAlbumsViewModel)
viewModelOf(::LibraryArtistsViewModel)
viewModelOf(::LibraryLocalTracksViewModel)

View File

@ -3,6 +3,7 @@
*/
package org.spotube.spotube.modules.library.playlist
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
@ -14,6 +15,7 @@ import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.composeunstyled.Text
import org.spotube.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylist
import org.spotube.spotube.modules.library.playlist.LocalPlaylistViewModel
import org.spotube.spotube.core.ui.component.ErrorDisplay
import org.spotube.spotube.core.ui.component.LikedTracksCard
import org.spotube.spotube.core.ui.component.PlaylistCard
@ -29,9 +31,11 @@ fun LibraryPlaylistsScreen(
modifier: Modifier = Modifier,
showCreatePlaylist: Boolean = false,
viewMode: LibraryViewMode = LibraryViewMode.Grid,
viewModel: LibraryPlaylistsViewModel = koinViewModel()
viewModel: LibraryPlaylistsViewModel = koinViewModel(),
localPlaylistViewModel: LocalPlaylistViewModel = koinViewModel()
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
val localState by localPlaylistViewModel.uiState.collectAsStateWithLifecycle()
val playlistFormState by viewModel.playlistFormState.collectAsStateWithLifecycle()
Column(modifier) {
when (val current = state) {
@ -44,10 +48,17 @@ fun LibraryPlaylistsScreen(
else -> {
val data = current as? LibraryPlaylistsState.Data
if (data != null && data.items.isEmpty() && current !is LibraryPlaylistsState.Data.LoadingMore) {
val localData = localState as? LocalPlaylistScreenState.Data
val spotifyPlaylists = data?.items.orEmpty()
val localPlaylists = localData?.playlists.orEmpty()
val allPlaylists = spotifyPlaylists + localPlaylists
val hasPlaylists = allPlaylists.isNotEmpty()
val isLoading = current is LibraryPlaylistsState.Loading || localState is LocalPlaylistScreenState.Loading
if (!hasPlaylists && !isLoading) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text(
if (data.query.isNotBlank()) stringResource(
if (data?.query.isNotBlank() == true) stringResource(
Res.string.library_playlists_no_results,
data.query
)
@ -55,33 +66,94 @@ fun LibraryPlaylistsScreen(
)
}
} else {
LibraryMediaCollection<MetadataPlaylist>(
items = data?.items.orEmpty(), itemKey = { it.id }, viewMode = viewMode,
loading = current is LibraryPlaylistsState.Loading,
loadingMore = current is LibraryPlaylistsState.Data.LoadingMore,
onViewportChanged = viewModel::onItemsVisible,
header = { list ->
LikedTracksCard(
Modifier.fillMaxWidth(),
listMode = list
)
},
itemContent = { item, list ->
PlaylistCard(
item,
Modifier.fillMaxWidth(),
listMode = list
)
},
skeleton = { list ->
PlayableCard(
stringResource(Res.string.media_preview_title),
stringResource(Res.string.media_preview_subtitle),
modifier = Modifier.fillMaxWidth(),
listMode = list
Column(Modifier.fillMaxSize()) {
// Spotify Playlists Section
if (spotifyPlaylists.isNotEmpty() || data?.query.isBlank() == true) {
LibraryMediaCollection<MetadataPlaylist>(
items = spotifyPlaylists, itemKey = { it.id }, viewMode = viewMode,
loading = current is LibraryPlaylistsState.Loading,
loadingMore = current is LibraryPlaylistsState.Data.LoadingMore,
onViewportChanged = viewModel::onItemsVisible,
header = { list ->
Column(Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
stringResource(Res.string.library_playlists_spotify_section),
style = com.composeunstyled.Typography.h6
)
if (listMode == LibraryViewMode.Grid) {
// Show create playlist button in header for Spotify playlists
}
}
}
},
itemContent = { item, list ->
PlaylistCard(
item,
Modifier.fillMaxWidth(),
listMode = list
)
},
skeleton = { list ->
PlayableCard(
stringResource(Res.string.media_preview_title),
stringResource(Res.string.media_preview_subtitle),
modifier = Modifier.fillMaxWidth(),
listMode = list
)
}
)
}
)
// Local Playlists Section
if (localPlaylists.isNotEmpty()) {
LibraryMediaCollection<LocalPlaylist>(
items = localPlaylists, itemKey = { it.id }, viewMode = viewMode,
loading = localState is LocalPlaylistScreenState.Loading,
loadingMore = false,
onViewportChanged = { },
header = { list ->
Column(Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
stringResource(Res.string.library_local_playlists_section),
style = com.composeunstyled.Typography.h6
)
MillenniumIconButton(
icon = Iconsax.IconsaxAddSquare,
contentDescription = stringResource(Res.string.library_action_create_playlist),
onClick = { localPlaylistViewModel.showCreatePlaylistDialog() },
style = MillenniumIconButtonStyle.Quiet
)
}
}
},
itemContent = { item, list ->
LocalPlaylistCard(
item,
Modifier.fillMaxWidth(),
listMode = list,
onClick = { },
onMoreClick = { localPlaylistViewModel.showEditPlaylistDialog(item) },
onPlayClick = { }
)
},
skeleton = { list ->
PlayableCard(
stringResource(Res.string.media_preview_title),
stringResource(Res.string.media_preview_subtitle),
modifier = Modifier.fillMaxWidth(),
listMode = list
)
}
)
}
}
}
}

View File

@ -0,0 +1,110 @@
/*
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.spotube.spotube.modules.library.playlist
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.overflow.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.composeunstyled.Text
import com.composeunstyled.Typography
import org.spotube.millennium.composables.MillenniumButton
import org.spotube.millennium.composables.MillenniumButtonStyle
import org.spotube.millennium.composables.MillenniumIconButton
import org.spotube.millennium.composables.MillenniumIconButtonStyle
import org.spotube.spotube.core.audioplayer.AudioPlayerInterface
import org.spotube.spotube.core.audioplayer.AudioPlayerQueue
import org.spotube.spotube.core.audioplayer.QueueEntry
import org.spotube.spotube.core.audioplayer.PlayerState
import org.spotube.spotube.core.navigation.NavigationCommands
import org.spotube.spotube.core.ui.component.AsyncImage
import org.spotube.spotube.core.ui.component.cards.PlayableCard
import org.spotube.spotube.modules.library.local_tracks.media.LocalMediaTrack
import org.spotube.spotube.resources.iconsax.Iconsax
import org.spotube.spotube.resources.iconsax.IconsaxAddSquare
import org.spotube.spotube.resources.iconsax.IconsaxMoreVertical
import org.spotube.spotube.resources.iconsax.IconsaxPlay
import org.jetbrains.compose.resources.stringResource
import org.koin.compose.koinInject
import spotube.composeapp.generated.resources.Res
import spotube.composeapp.generated.resources.plurals.library_local_tracks_count
@Composable
fun LocalPlaylistCard(
playlist: LocalPlaylist,
modifier: Modifier = Modifier,
listMode: Boolean = false,
onClick: () -> Unit = {},
onMoreClick: () -> Unit = {},
onPlayClick: () -> Unit = {}
) {
val trackCount = playlist.trackPaths.size
val artworkUrl = if (playlist.trackPaths.isNotEmpty()) {
// Try to get artwork from the first track
"" // Will be resolved by the AsyncImage
} else ""
PlayableCard(
title = playlist.name,
subtitle = pluralStringResource(Res.plurals.library_local_tracks_count, trackCount, trackCount),
imageUrl = artworkUrl,
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 4.dp),
listMode = listMode,
onClick = onClick,
onPlayClick = onPlayClick,
trailingContent = {
MillenniumIconButton(
icon = Iconsax.IconsaxMoreVertical,
contentDescription = stringResource(Res.string.common_more_options),
onClick = onMoreClick,
style = MillenniumIconButtonStyle.Quiet
)
},
onLongClick = onMoreClick,
subtitleContent = {
if (playlist.description != null && playlist.description!.isNotBlank()) {
Text(
text = playlist.description!,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
style = Typography.bodySmall,
color = Color.Gray
)
}
}
)
}

View File

@ -0,0 +1,312 @@
/*
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.spotube.spotube.modules.library.playlist
import org.spotube.spotube.modules.library.local_tracks.media.LocalMediaTrack
import org.spotube.spotube.modules.settings.SettingsRepository
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.launch
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import java.io.File
import java.nio.charset.StandardCharsets
import org.jetbrains.compose.resources.getString
import spotube.composeapp.generated.resources.Res
import org.koin.core.component.KoinComponent
/**
* Repository for managing local playlists (stored as M3U8 files).
*
* Local playlists are stored as M3U8 files in the configured playlists directory.
* They contain local tracks (from LocalMediaTrack) referenced by their file path.
*/
@OptIn(ExperimentalCoroutinesApi::class)
class LocalPlaylistRepository(
private val settingsRepository: SettingsRepository
) : KoinComponent {
private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
private val json = Json {
ignoreUnknownKeys = true
encodeDefaults = true
}
private val _playlists = MutableStateFlow<List<LocalPlaylist>>(emptyList())
val playlists: StateFlow<List<LocalPlaylist>> = _playlists.asStateFlow()
private var isInitialized = false
init {
loadPlaylists()
}
private val playlistsDir: String
get() = settingsRepository.userSettings.value.overloadedDownloadFolder
?.let { File(it, "Playlists").absolutePath }
?: File(settingsRepository.userSettings.value.localMediaFolders.firstOrNull() ?: File(System.getProperty("user.home"), "Music").absolutePath).parentFile.absolutePath
private val playlistsDirFile: File
get() = File(playlistsDir).apply { mkdirs() }
private fun getPlaylistFile(name: String): File {
val safeName = name.replace(Regex(r"[<>:\"/\\|?*]"), "_")
return File(playlistsDirFile, "$safeName.m3u8")
}
fun initialize() {
if (!isInitialized) {
loadPlaylists()
isInitialized = true
}
}
fun getPlaylists(): List<LocalPlaylist> = _playlists.value
fun getPlaylist(name: String): LocalPlaylist? = _playlists.value.find { it.name == name }
suspend fun createPlaylist(name: String, description: String? = null): Result<LocalPlaylist> {
return runCatching {
if (name.isBlank()) {
throw IllegalArgumentException(getString(Res.string.library_playlist_name_empty))
}
if (_playlists.value.any { it.name == name }) {
throw IllegalArgumentException(getString(Res.string.library_playlist_name_exists))
}
val playlist = LocalPlaylist(
id = java.util.UUID.randomUUID().toString(),
name = name,
description = description,
createdAt = System.currentTimeMillis(),
updatedAt = System.currentTimeMillis(),
trackPaths = emptyList()
)
_playlists.update { it + playlist }
savePlaylist(playlist)
playlist
}
}
suspend fun updatePlaylist(playlist: LocalPlaylist): Result<Unit> {
return runCatching {
val index = _playlists.value.indexOfFirst { it.id == playlist.id }
if (index < 0) {
throw IllegalArgumentException(getString(Res.string.library_playlist_not_found))
}
val updated = playlist.copy(
updatedAt = System.currentTimeMillis()
)
_playlists.update { list ->
list.set(index, updated)
}
savePlaylist(updated)
}
}
suspend fun deletePlaylist(playlistId: String): Result<Unit> {
return runCatching {
val playlist = _playlists.value.find { it.id == playlistId }
if (playlist == null) {
throw IllegalArgumentException(getString(Res.string.library_playlist_not_found))
}
val file = getPlaylistFile(playlist.name)
file.delete()
_playlists.update { it.filter { it.id != playlistId } }
}
}
suspend fun addTrack(playlistId: String, track: LocalMediaTrack): Result<Unit> {
return runCatching {
val playlist = _playlists.value.find { it.id == playlistId }
?: throw IllegalArgumentException(getString(Res.string.library_playlist_not_found))
val trackPath = track.path
if (playlist.trackPaths.contains(trackPath)) {
return // Already in playlist
}
val updated = playlist.copy(
trackPaths = playlist.trackPaths + trackPath,
updatedAt = System.currentTimeMillis()
)
_playlists.update { list ->
list.map { if (it.id == playlistId) updated else it }
}
savePlaylist(updated)
}
}
suspend fun addTracks(playlistId: String, tracks: List<LocalMediaTrack>): Result<Unit> {
return runCatching {
val playlist = _playlists.value.find { it.id == playlistId }
?: throw IllegalArgumentException(getString(Res.string.library_playlist_not_found))
val newTrackPaths = tracks.map { it.path }.filterNot { it in playlist.trackPaths }
if (newTrackPaths.isEmpty()) return
val updated = playlist.copy(
trackPaths = playlist.trackPaths + newTrackPaths,
updatedAt = System.currentTimeMillis()
)
_playlists.update { list ->
list.map { if (it.id == playlistId) updated else it }
}
savePlaylist(updated)
}
}
suspend fun removeTrack(playlistId: String, trackPath: String): Result<Unit> {
return runCatching {
val playlist = _playlists.value.find { it.id == playlistId }
?: throw IllegalArgumentException(getString(Res.string.library_playlist_not_found))
if (!playlist.trackPaths.contains(trackPath)) return
val updated = playlist.copy(
trackPaths = playlist.trackPaths.filter { it != trackPath },
updatedAt = System.currentTimeMillis()
)
_playlists.update { list ->
list.map { if (it.id == playlistId) updated else it }
}
savePlaylist(updated)
}
}
suspend fun reorderTracks(playlistId: String, fromIndex: Int, toIndex: Int): Result<Unit> {
return runCatching {
val playlist = _playlists.value.find { it.id == playlistId }
?: throw IllegalArgumentException(getString(Res.string.library_playlist_not_found))
if (fromIndex < 0 || fromIndex >= playlist.trackPaths.size ||
toIndex < 0 || toIndex >= playlist.trackPaths.size) {
throw IndexOutOfBoundsException()
}
val newTrackPaths = playlist.trackPaths.toMutableList()
val trackPath = newTrackPaths.removeAt(fromIndex)
newTrackPaths.add(toIndex, trackPath)
val updated = playlist.copy(
trackPaths = newTrackPaths,
updatedAt = System.currentTimeMillis()
)
_playlists.update { list ->
list.map { if (it.id == playlistId) updated else it }
}
savePlaylist(updated)
}
}
private fun savePlaylist(playlist: LocalPlaylist) {
scope.launch {
val file = getPlaylistFile(playlist.name)
val m3u8Content = buildM3u8Content(playlist)
file.writeText(m3u8Content, StandardCharsets.UTF_8)
}
}
private fun buildM3u8Content(playlist: LocalPlaylist): String {
val sb = StringBuilder()
sb.append("#EXTM3U\n")
sb.append("#PLAYLIST:${playlist.name}\n")
if (playlist.description != null) {
sb.append("#DESCRIPTION:${playlist.description}\n")
}
sb.append("#EXTENC:UTF-8\n")
for (trackPath in playlist.trackPaths) {
val trackName = File(trackPath).nameWithoutExtension
sb.append("#EXTINF:-1,$trackName\n")
sb.append("$trackPath\n")
}
return sb.toString()
}
private fun loadPlaylists() {
scope.launch {
val files = playlistsDirFile.listFiles { it.extension == "m3u8" } ?: emptyArray()
val playlists = files.mapNotNull { file ->
parseM3u8(file).copy(filePath = file.absolutePath)
}
_playlists.value = playlists
}
}
private fun parseM3u8(file: File): LocalPlaylist {
val lines = file.readText(StandardCharsets.UTF_8).splitLines()
var name = file.nameWithoutExtension
var description: String? = null
var trackPaths = mutableListOf<String>()
for (line in lines) {
when {
line.startsWith("#PLAYLIST:") -> name = line.substringAfter("#PLAYLIST:").trim()
line.startsWith("#DESCRIPTION:") -> description = line.substringAfter("#DESCRIPTION:").trim()
line.startsWith("#EXTINF:") -> {
// Format: #EXTINF:-1,trackName
// Next line is the track path
}
!line.startsWith("#") && line.isNotBlank() -> {
// This is a track path line
val trackPath = line.trim()
if (trackPath.isNotBlank()) trackPaths.add(trackPath)
}
}
}
return LocalPlaylist(
id = file.nameWithoutExtension,
name = name,
description = description,
createdAt = file.lastModified(),
updatedAt = file.lastModified(),
trackPaths = trackPaths,
filePath = file.absolutePath
)
}
}
@Serializable
data class LocalPlaylist(
val id: String,
val name: String,
val description: String? = null,
val createdAt: Long,
val updatedAt: Long,
val trackPaths: List<String> = emptyList(),
val filePath: String? = null
)

View File

@ -0,0 +1,298 @@
/*
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.spotube.spotube.modules.library.playlist
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import org.spotube.spotube.core.di.injectLogger
import org.spotube.spotube.modules.library.local_tracks.LibraryLocalTracksViewModel
import org.spotube.spotube.modules.library.local_tracks.media.LocalMediaTrack
import org.spotube.spotube.modules.settings.SettingsProvider
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.coroutines.flow.SharingStarted
import org.jetbrains.compose.resources.getString
import spotube.composeapp.generated.resources.Res
import io.github.vinceglb.filekit.PlatformFile
import kotlinx.coroutines.tasks.await
import io.github.vinceglb.filekit.writeText
sealed interface LocalPlaylistScreenState {
data class Data(
val playlists: List<LocalPlaylist> = emptyList(),
val query: String = "",
val selectedPlaylist: LocalPlaylist? = null,
val showCreateDialog: Boolean = false,
val showEditDialog: Boolean = false,
val showAddTracksDialog: Boolean = false,
val editingPlaylist: LocalPlaylist? = null,
val isLoading: Boolean = false,
val error: String? = null
) : LocalPlaylistScreenState
data object Loading : LocalPlaylistScreenState
data class Error(val message: String) : LocalPlaylistScreenState
}
@OptIn(ExperimentalCoroutinesApi::class)
class LocalPlaylistViewModel(
private val repository: LocalPlaylistRepository,
private val localTracksViewModel: LibraryLocalTracksViewModel,
private val settingsProvider: SettingsProvider
) : ViewModel() {
private val logger by injectLogger<LocalPlaylistViewModel>()
private val _uiState = MutableStateFlow<LocalPlaylistScreenState>(LocalPlaylistScreenState.Loading)
val uiState: StateFlow<LocalPlaylistScreenState> = _uiState.asStateFlow()
private val _query = mutableStateOf("")
var query: String
get() = _query.value
set(value) { _query.value = value }
private val _showCreateDialog = mutableStateOf(false)
var showCreateDialog: Boolean
get() = _showCreateDialog.value
set(value) { _showCreateDialog.value = value }
private val _showEditDialog = mutableStateOf(false)
var showEditDialog: Boolean
get() = _showEditDialog.value
set(value) { _showEditDialog.value = value }
private val _showAddTracksDialog = mutableStateOf(false)
var showAddTracksDialog: Boolean
get() = _showAddTracksDialog.value
set(value) { _showAddTracksDialog.value = value }
private val _editingPlaylist = MutableStateFlow<LocalPlaylist?>(null)
val editingPlaylist: StateFlow<LocalPlaylist?> = _editingPlaylist.asStateFlow()
private val _showAddTracksForPlaylist = MutableStateFlow<LocalPlaylist?>(null)
val showAddTracksForPlaylist: StateFlow<LocalPlaylist?> = _showAddTracksForPlaylist.asStateFlow()
init {
viewModelScope.launch {
repository.playlists
.combine(localTracksViewModel.state) { playlists, localTracksState ->
LocalPlaylistScreenState.Data(
playlists = playlists,
query = "",
selectedPlaylist = null
)
}
.stateIn(
viewModelScope,
SharingStarted.WhileSubscribed(5_000),
LocalPlaylistScreenState.Loading
)
.collect { state ->
_uiState.value = state
}
}
}
fun onQueryChange(query: String) {
_uiState.update { state ->
if (state is LocalPlaylistScreenState.Data) {
state.copy(query = query)
} else state
}
}
fun showCreatePlaylistDialog() {
_showCreateDialog.value = true
}
fun dismissCreatePlaylistDialog() {
_showCreateDialog.value = false
}
fun createPlaylist(name: String, description: String? = null) {
viewModelScope.launch {
_uiState.update { it.copy(isLoading = true) }
repository.createPlaylist(name, description)
.onSuccess { playlist ->
_uiState.update { state ->
if (state is LocalPlaylistScreenState.Data) {
state.copy(playlists = state.playlists + playlist, isLoading = false)
} else state
}
dismissCreatePlaylistDialog()
}
.onFailure { e ->
_uiState.update { state ->
if (state is LocalPlaylistScreenState.Data) {
state.copy(isLoading = false, error = e.message)
} else LocalPlaylistScreenState.Error(e.message ?: getString(Res.string.common_unknown_error))
}
}
}
}
fun showEditPlaylistDialog(playlist: LocalPlaylist) {
_editingPlaylist.value = playlist
_showEditDialog.value = true
}
fun dismissEditPlaylistDialog() {
_showEditDialog.value = false
_editingPlaylist.value = null
}
fun updatePlaylist(playlist: LocalPlaylist) {
viewModelScope.launch {
repository.updatePlaylist(playlist)
.onSuccess {
_uiState.update { state ->
if (state is LocalPlaylistScreenState.Data) {
val updatedPlaylists = state.playlists.map {
if (it.id == playlist.id) playlist else it
}
state.copy(playlists = updatedPlaylists, editingPlaylist = null)
} else state
}
dismissEditPlaylistDialog()
}
.onFailure { e ->
_uiState.update { state ->
if (state is LocalPlaylistScreenState.Data) {
state.copy(error = e.message)
} else state
}
}
}
}
fun deletePlaylist(playlistId: String) {
viewModelScope.launch {
repository.deletePlaylist(playlistId)
.onSuccess {
_uiState.update { state ->
if (state is LocalPlaylistScreenState.Data) {
state.copy(playlists = state.playlists.filter { it.id != playlistId })
} else state
}
}
.onFailure { e ->
_uiState.update { state ->
if (state is LocalPlaylistScreenState.Data) {
state.copy(error = e.message)
} else state
}
}
}
}
fun showAddTracksDialog(playlist: LocalPlaylist) {
_showAddTracksForPlaylist.value = playlist
_showAddTracksDialog.value = true
}
fun dismissAddTracksDialog() {
_showAddTracksDialog.value = false
_showAddTracksForPlaylist.value = null
}
fun addTracks(playlist: LocalPlaylist, tracks: List<LocalMediaTrack>) {
viewModelScope.launch {
repository.addTracks(playlist.id, tracks)
.onSuccess {
_uiState.update { state ->
if (state is LocalPlaylistScreenState.Data) {
val updatedPlaylists = state.playlists.map {
if (it.id == playlist.id)
it.copy(trackPaths = it.trackPaths + tracks.map { it.path }.filterNot { it in it.trackPaths })
else it
}
state.copy(playlists = updatedPlaylists)
} else state
}
dismissAddTracksDialog()
}
.onFailure { e ->
_uiState.update { state ->
if (state is LocalPlaylistScreenState.Data) {
state.copy(error = e.message)
} else state
}
}
}
}
fun removeTrack(playlistId: String, trackPath: String) {
viewModelScope.launch {
repository.removeTrack(playlistId, trackPath)
.onSuccess {
_uiState.update { state ->
if (state is LocalPlaylistScreenState.Data) {
val updatedPlaylists = state.playlists.map {
if (it.id == playlistId)
it.copy(trackPaths = it.trackPaths.filter { it != trackPath })
else it
}
state.copy(playlists = updatedPlaylists)
} else state
}
}
.onFailure { e ->
_uiState.update { state ->
if (state is LocalPlaylistScreenState.Data) {
state.copy(error = e.message)
} else state
}
}
}
}
fun reorderTracks(playlistId: String, fromIndex: Int, toIndex: Int) {
viewModelScope.launch {
repository.reorderTracks(playlistId, fromIndex, toIndex)
.onSuccess {
_uiState.update { state ->
if (state is LocalPlaylistScreenState.Data) {
val updatedPlaylists = state.playlists.map {
if (it.id == playlistId) {
val newTrackPaths = it.trackPaths.toMutableList()
val trackPath = newTrackPaths.removeAt(fromIndex)
newTrackPaths.add(toIndex, trackPath)
it.copy(trackPaths = newTrackPaths)
} else it
}
state.copy(playlists = updatedPlaylists)
} else state
}
}
.onFailure { e ->
_uiState.update { state ->
if (state is LocalPlaylistScreenState.Data) {
state.copy(error = e.message)
} else state
}
}
}
}
}