diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index 2fbae5e0..6bcab2be 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -45,6 +45,10 @@ Switch to list view No saved playlists No playlists match “%1$s” + No local playlists yet + Playlist name cannot be empty + A playlist with this name already exists + Playlist not found No saved albums No albums match “%1$s” No saved artists @@ -74,6 +78,9 @@ No matching folders or tracks Failed to refresh local media + Spotify Playlists + Local Playlists + %1$d active • %2$d completed Saved to %1$s diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/di/Modules.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/di/Modules.kt index d7668946..038d70e7 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/di/Modules.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/di/Modules.kt @@ -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) diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/playlist/LibraryPlaylistsScreen.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/playlist/LibraryPlaylistsScreen.kt index 85a7f52a..b250240d 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/playlist/LibraryPlaylistsScreen.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/playlist/LibraryPlaylistsScreen.kt @@ -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( - 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( + 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( + 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 + ) + } + ) + } } } } @@ -93,4 +165,4 @@ fun LibraryPlaylistsScreen( onImagePicked = viewModel::onPlaylistFormImagePicked ) } -} +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/playlist/LocalPlaylistCard.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/playlist/LocalPlaylistCard.kt new file mode 100644 index 00000000..ba2180dc --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/playlist/LocalPlaylistCard.kt @@ -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 . + */ + +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 + ) + } + } + ) +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/playlist/LocalPlaylistRepository.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/playlist/LocalPlaylistRepository.kt new file mode 100644 index 00000000..5ca556e1 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/playlist/LocalPlaylistRepository.kt @@ -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 . + */ + +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>(emptyList()) + val playlists: StateFlow> = _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 = _playlists.value + + fun getPlaylist(name: String): LocalPlaylist? = _playlists.value.find { it.name == name } + + suspend fun createPlaylist(name: String, description: String? = null): Result { + 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 { + 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 { + 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 { + 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): Result { + 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 { + 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 { + 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() + + 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 = emptyList(), + val filePath: String? = null +) \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/playlist/LocalPlaylistViewModel.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/playlist/LocalPlaylistViewModel.kt new file mode 100644 index 00000000..ca718f95 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/playlist/LocalPlaylistViewModel.kt @@ -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 . + */ + +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 = 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() + + private val _uiState = MutableStateFlow(LocalPlaylistScreenState.Loading) + val uiState: StateFlow = _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(null) + val editingPlaylist: StateFlow = _editingPlaylist.asStateFlow() + + private val _showAddTracksForPlaylist = MutableStateFlow(null) + val showAddTracksForPlaylist: StateFlow = _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) { + 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 + } + } + } + } +} \ No newline at end of file