From dbf63637ba641132ebbb1824e8a2ca41b74b33de Mon Sep 17 00:00:00 2001 From: thumb2086 Date: Sat, 31 Oct 2026 04:12:07 +0800 Subject: [PATCH] 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. --- .../composeResources/values/strings.xml | 7 + .../org/spotube/spotube/core/di/Modules.kt | 8 + .../playlist/LibraryPlaylistsScreen.kt | 130 ++++++-- .../library/playlist/LocalPlaylistCard.kt | 110 ++++++ .../playlist/LocalPlaylistRepository.kt | 312 ++++++++++++++++++ .../playlist/LocalPlaylistViewModel.kt | 298 +++++++++++++++++ 6 files changed, 836 insertions(+), 29 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/playlist/LocalPlaylistCard.kt create mode 100644 composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/playlist/LocalPlaylistRepository.kt create mode 100644 composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/playlist/LocalPlaylistViewModel.kt 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