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