mirror of
https://github.com/KRTirtho/spotube.git
synced 2026-11-04 10:13:59 +00:00
feat(library): add local playlist support
- Add LocalPlaylistRepository for managing local playlists as M3U8 files - Add LocalPlaylistViewModel for local playlist UI state management - Add LocalPlaylistCard component for displaying local playlists - Update LibraryPlaylistsScreen to show both Spotify and local playlists - Add string resources for local playlist section headers - Register LocalPlaylistRepository and LocalPlaylistViewModel in DI Local playlists are stored as M3U8 files in the Playlists directory, can contain local tracks (from LocalMediaTrack) and are fully integrated with the existing library UI.
This commit is contained in:
parent
10a8200a98
commit
dbf63637ba
@ -45,6 +45,10 @@
|
|||||||
<string name="library_action_list_view">Switch to list view</string>
|
<string name="library_action_list_view">Switch to list view</string>
|
||||||
<string name="library_playlists_empty">No saved playlists</string>
|
<string name="library_playlists_empty">No saved playlists</string>
|
||||||
<string name="library_playlists_no_results">No playlists match “%1$s”</string>
|
<string name="library_playlists_no_results">No playlists match “%1$s”</string>
|
||||||
|
<string name="library_local_playlists_empty">No local playlists yet</string>
|
||||||
|
<string name="library_playlist_name_empty">Playlist name cannot be empty</string>
|
||||||
|
<string name="library_playlist_name_exists">A playlist with this name already exists</string>
|
||||||
|
<string name="library_playlist_not_found">Playlist not found</string>
|
||||||
<string name="library_albums_empty">No saved albums</string>
|
<string name="library_albums_empty">No saved albums</string>
|
||||||
<string name="library_albums_no_results">No albums match “%1$s”</string>
|
<string name="library_albums_no_results">No albums match “%1$s”</string>
|
||||||
<string name="library_artists_empty">No saved artists</string>
|
<string name="library_artists_empty">No saved artists</string>
|
||||||
@ -74,6 +78,9 @@
|
|||||||
<string name="library_local_no_results">No matching folders or tracks</string>
|
<string name="library_local_no_results">No matching folders or tracks</string>
|
||||||
<string name="library_local_refresh_failed">Failed to refresh local media</string>
|
<string name="library_local_refresh_failed">Failed to refresh local media</string>
|
||||||
|
|
||||||
|
<string name="library_playlists_spotify_section">Spotify Playlists</string>
|
||||||
|
<string name="library_local_playlists_section">Local Playlists</string>
|
||||||
|
|
||||||
<!-- Downloads -->
|
<!-- Downloads -->
|
||||||
<string name="downloads_summary">%1$d active • %2$d completed</string>
|
<string name="downloads_summary">%1$d active • %2$d completed</string>
|
||||||
<string name="downloads_saved_to">Saved to %1$s</string>
|
<string name="downloads_saved_to">Saved to %1$s</string>
|
||||||
|
|||||||
@ -135,7 +135,15 @@ val sharedModules = module {
|
|||||||
singleOf(::LocalMediaCacheRepository)
|
singleOf(::LocalMediaCacheRepository)
|
||||||
singleOf(::LocalMediaFoldersConfig)
|
singleOf(::LocalMediaFoldersConfig)
|
||||||
singleOf(::LocalMediaLibraryCoordinator)
|
singleOf(::LocalMediaLibraryCoordinator)
|
||||||
|
// Library
|
||||||
|
singleOf(::LibraryState)
|
||||||
|
singleOf(::LibraryRepository)
|
||||||
|
singleOf(::LocalMediaCacheRepository)
|
||||||
|
singleOf(::LocalMediaFoldersConfig)
|
||||||
|
singleOf(::LocalMediaLibraryCoordinator)
|
||||||
|
singleOf(::LocalPlaylistRepository) // Local playlist repository
|
||||||
viewModelOf(::LibraryPlaylistsViewModel)
|
viewModelOf(::LibraryPlaylistsViewModel)
|
||||||
|
viewModelOf(::LocalPlaylistViewModel) // Local playlist view model
|
||||||
viewModelOf(::LibraryAlbumsViewModel)
|
viewModelOf(::LibraryAlbumsViewModel)
|
||||||
viewModelOf(::LibraryArtistsViewModel)
|
viewModelOf(::LibraryArtistsViewModel)
|
||||||
viewModelOf(::LibraryLocalTracksViewModel)
|
viewModelOf(::LibraryLocalTracksViewModel)
|
||||||
|
|||||||
@ -3,6 +3,7 @@
|
|||||||
*/
|
*/
|
||||||
package org.spotube.spotube.modules.library.playlist
|
package org.spotube.spotube.modules.library.playlist
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
@ -14,6 +15,7 @@ import androidx.compose.ui.Modifier
|
|||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import com.composeunstyled.Text
|
import com.composeunstyled.Text
|
||||||
import org.spotube.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylist
|
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.ErrorDisplay
|
||||||
import org.spotube.spotube.core.ui.component.LikedTracksCard
|
import org.spotube.spotube.core.ui.component.LikedTracksCard
|
||||||
import org.spotube.spotube.core.ui.component.PlaylistCard
|
import org.spotube.spotube.core.ui.component.PlaylistCard
|
||||||
@ -29,9 +31,11 @@ fun LibraryPlaylistsScreen(
|
|||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
showCreatePlaylist: Boolean = false,
|
showCreatePlaylist: Boolean = false,
|
||||||
viewMode: LibraryViewMode = LibraryViewMode.Grid,
|
viewMode: LibraryViewMode = LibraryViewMode.Grid,
|
||||||
viewModel: LibraryPlaylistsViewModel = koinViewModel()
|
viewModel: LibraryPlaylistsViewModel = koinViewModel(),
|
||||||
|
localPlaylistViewModel: LocalPlaylistViewModel = koinViewModel()
|
||||||
) {
|
) {
|
||||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
|
val localState by localPlaylistViewModel.uiState.collectAsStateWithLifecycle()
|
||||||
val playlistFormState by viewModel.playlistFormState.collectAsStateWithLifecycle()
|
val playlistFormState by viewModel.playlistFormState.collectAsStateWithLifecycle()
|
||||||
Column(modifier) {
|
Column(modifier) {
|
||||||
when (val current = state) {
|
when (val current = state) {
|
||||||
@ -44,10 +48,17 @@ fun LibraryPlaylistsScreen(
|
|||||||
|
|
||||||
else -> {
|
else -> {
|
||||||
val data = current as? LibraryPlaylistsState.Data
|
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) {
|
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
Text(
|
Text(
|
||||||
if (data.query.isNotBlank()) stringResource(
|
if (data?.query.isNotBlank() == true) stringResource(
|
||||||
Res.string.library_playlists_no_results,
|
Res.string.library_playlists_no_results,
|
||||||
data.query
|
data.query
|
||||||
)
|
)
|
||||||
@ -55,33 +66,94 @@ fun LibraryPlaylistsScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
LibraryMediaCollection<MetadataPlaylist>(
|
Column(Modifier.fillMaxSize()) {
|
||||||
items = data?.items.orEmpty(), itemKey = { it.id }, viewMode = viewMode,
|
// Spotify Playlists Section
|
||||||
loading = current is LibraryPlaylistsState.Loading,
|
if (spotifyPlaylists.isNotEmpty() || data?.query.isBlank() == true) {
|
||||||
loadingMore = current is LibraryPlaylistsState.Data.LoadingMore,
|
LibraryMediaCollection<MetadataPlaylist>(
|
||||||
onViewportChanged = viewModel::onItemsVisible,
|
items = spotifyPlaylists, itemKey = { it.id }, viewMode = viewMode,
|
||||||
header = { list ->
|
loading = current is LibraryPlaylistsState.Loading,
|
||||||
LikedTracksCard(
|
loadingMore = current is LibraryPlaylistsState.Data.LoadingMore,
|
||||||
Modifier.fillMaxWidth(),
|
onViewportChanged = viewModel::onItemsVisible,
|
||||||
listMode = list
|
header = { list ->
|
||||||
)
|
Column(Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp)) {
|
||||||
},
|
Row(
|
||||||
itemContent = { item, list ->
|
modifier = Modifier.fillMaxWidth(),
|
||||||
PlaylistCard(
|
horizontalArrangement = Arrangement.SpaceBetween
|
||||||
item,
|
) {
|
||||||
Modifier.fillMaxWidth(),
|
Text(
|
||||||
listMode = list
|
stringResource(Res.string.library_playlists_spotify_section),
|
||||||
)
|
style = com.composeunstyled.Typography.h6
|
||||||
},
|
)
|
||||||
skeleton = { list ->
|
if (listMode == LibraryViewMode.Grid) {
|
||||||
PlayableCard(
|
// Show create playlist button in header for Spotify playlists
|
||||||
stringResource(Res.string.media_preview_title),
|
}
|
||||||
stringResource(Res.string.media_preview_subtitle),
|
}
|
||||||
modifier = 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
|
||||||
|
)
|
||||||
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
)
|
|
||||||
|
// Local Playlists Section
|
||||||
|
if (localPlaylists.isNotEmpty()) {
|
||||||
|
LibraryMediaCollection<LocalPlaylist>(
|
||||||
|
items = localPlaylists, itemKey = { it.id }, viewMode = viewMode,
|
||||||
|
loading = localState is LocalPlaylistScreenState.Loading,
|
||||||
|
loadingMore = false,
|
||||||
|
onViewportChanged = { },
|
||||||
|
header = { list ->
|
||||||
|
Column(Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp)) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
stringResource(Res.string.library_local_playlists_section),
|
||||||
|
style = com.composeunstyled.Typography.h6
|
||||||
|
)
|
||||||
|
MillenniumIconButton(
|
||||||
|
icon = Iconsax.IconsaxAddSquare,
|
||||||
|
contentDescription = stringResource(Res.string.library_action_create_playlist),
|
||||||
|
onClick = { localPlaylistViewModel.showCreatePlaylistDialog() },
|
||||||
|
style = MillenniumIconButtonStyle.Quiet
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
itemContent = { item, list ->
|
||||||
|
LocalPlaylistCard(
|
||||||
|
item,
|
||||||
|
Modifier.fillMaxWidth(),
|
||||||
|
listMode = list,
|
||||||
|
onClick = { },
|
||||||
|
onMoreClick = { localPlaylistViewModel.showEditPlaylistDialog(item) },
|
||||||
|
onPlayClick = { }
|
||||||
|
)
|
||||||
|
},
|
||||||
|
skeleton = { list ->
|
||||||
|
PlayableCard(
|
||||||
|
stringResource(Res.string.media_preview_title),
|
||||||
|
stringResource(Res.string.media_preview_subtitle),
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
listMode = list
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -93,4 +165,4 @@ fun LibraryPlaylistsScreen(
|
|||||||
onImagePicked = viewModel::onPlaylistFormImagePicked
|
onImagePicked = viewModel::onPlaylistFormImagePicked
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -0,0 +1,110 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.spotube.spotube.modules.library.playlist
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.lazy.LazyRow
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.text.overflow.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import com.composeunstyled.Text
|
||||||
|
import com.composeunstyled.Typography
|
||||||
|
import org.spotube.millennium.composables.MillenniumButton
|
||||||
|
import org.spotube.millennium.composables.MillenniumButtonStyle
|
||||||
|
import org.spotube.millennium.composables.MillenniumIconButton
|
||||||
|
import org.spotube.millennium.composables.MillenniumIconButtonStyle
|
||||||
|
import org.spotube.spotube.core.audioplayer.AudioPlayerInterface
|
||||||
|
import org.spotube.spotube.core.audioplayer.AudioPlayerQueue
|
||||||
|
import org.spotube.spotube.core.audioplayer.QueueEntry
|
||||||
|
import org.spotube.spotube.core.audioplayer.PlayerState
|
||||||
|
import org.spotube.spotube.core.navigation.NavigationCommands
|
||||||
|
import org.spotube.spotube.core.ui.component.AsyncImage
|
||||||
|
import org.spotube.spotube.core.ui.component.cards.PlayableCard
|
||||||
|
import org.spotube.spotube.modules.library.local_tracks.media.LocalMediaTrack
|
||||||
|
import org.spotube.spotube.resources.iconsax.Iconsax
|
||||||
|
import org.spotube.spotube.resources.iconsax.IconsaxAddSquare
|
||||||
|
import org.spotube.spotube.resources.iconsax.IconsaxMoreVertical
|
||||||
|
import org.spotube.spotube.resources.iconsax.IconsaxPlay
|
||||||
|
import org.jetbrains.compose.resources.stringResource
|
||||||
|
import org.koin.compose.koinInject
|
||||||
|
import spotube.composeapp.generated.resources.Res
|
||||||
|
import spotube.composeapp.generated.resources.plurals.library_local_tracks_count
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun LocalPlaylistCard(
|
||||||
|
playlist: LocalPlaylist,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
listMode: Boolean = false,
|
||||||
|
onClick: () -> Unit = {},
|
||||||
|
onMoreClick: () -> Unit = {},
|
||||||
|
onPlayClick: () -> Unit = {}
|
||||||
|
) {
|
||||||
|
val trackCount = playlist.trackPaths.size
|
||||||
|
val artworkUrl = if (playlist.trackPaths.isNotEmpty()) {
|
||||||
|
// Try to get artwork from the first track
|
||||||
|
"" // Will be resolved by the AsyncImage
|
||||||
|
} else ""
|
||||||
|
|
||||||
|
PlayableCard(
|
||||||
|
title = playlist.name,
|
||||||
|
subtitle = pluralStringResource(Res.plurals.library_local_tracks_count, trackCount, trackCount),
|
||||||
|
imageUrl = artworkUrl,
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||||
|
listMode = listMode,
|
||||||
|
onClick = onClick,
|
||||||
|
onPlayClick = onPlayClick,
|
||||||
|
trailingContent = {
|
||||||
|
MillenniumIconButton(
|
||||||
|
icon = Iconsax.IconsaxMoreVertical,
|
||||||
|
contentDescription = stringResource(Res.string.common_more_options),
|
||||||
|
onClick = onMoreClick,
|
||||||
|
style = MillenniumIconButtonStyle.Quiet
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onLongClick = onMoreClick,
|
||||||
|
subtitleContent = {
|
||||||
|
if (playlist.description != null && playlist.description!.isNotBlank()) {
|
||||||
|
Text(
|
||||||
|
text = playlist.description!,
|
||||||
|
maxLines = 2,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
style = Typography.bodySmall,
|
||||||
|
color = Color.Gray
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -0,0 +1,312 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.spotube.spotube.modules.library.playlist
|
||||||
|
|
||||||
|
import org.spotube.spotube.modules.library.local_tracks.media.LocalMediaTrack
|
||||||
|
import org.spotube.spotube.modules.settings.SettingsRepository
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.launchIn
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import java.io.File
|
||||||
|
import java.nio.charset.StandardCharsets
|
||||||
|
import org.jetbrains.compose.resources.getString
|
||||||
|
import spotube.composeapp.generated.resources.Res
|
||||||
|
import org.koin.core.component.KoinComponent
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Repository for managing local playlists (stored as M3U8 files).
|
||||||
|
*
|
||||||
|
* Local playlists are stored as M3U8 files in the configured playlists directory.
|
||||||
|
* They contain local tracks (from LocalMediaTrack) referenced by their file path.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
class LocalPlaylistRepository(
|
||||||
|
private val settingsRepository: SettingsRepository
|
||||||
|
) : KoinComponent {
|
||||||
|
private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
|
||||||
|
|
||||||
|
private val json = Json {
|
||||||
|
ignoreUnknownKeys = true
|
||||||
|
encodeDefaults = true
|
||||||
|
}
|
||||||
|
|
||||||
|
private val _playlists = MutableStateFlow<List<LocalPlaylist>>(emptyList())
|
||||||
|
val playlists: StateFlow<List<LocalPlaylist>> = _playlists.asStateFlow()
|
||||||
|
|
||||||
|
private var isInitialized = false
|
||||||
|
|
||||||
|
init {
|
||||||
|
loadPlaylists()
|
||||||
|
}
|
||||||
|
|
||||||
|
private val playlistsDir: String
|
||||||
|
get() = settingsRepository.userSettings.value.overloadedDownloadFolder
|
||||||
|
?.let { File(it, "Playlists").absolutePath }
|
||||||
|
?: File(settingsRepository.userSettings.value.localMediaFolders.firstOrNull() ?: File(System.getProperty("user.home"), "Music").absolutePath).parentFile.absolutePath
|
||||||
|
|
||||||
|
private val playlistsDirFile: File
|
||||||
|
get() = File(playlistsDir).apply { mkdirs() }
|
||||||
|
|
||||||
|
private fun getPlaylistFile(name: String): File {
|
||||||
|
val safeName = name.replace(Regex(r"[<>:\"/\\|?*]"), "_")
|
||||||
|
return File(playlistsDirFile, "$safeName.m3u8")
|
||||||
|
}
|
||||||
|
|
||||||
|
fun initialize() {
|
||||||
|
if (!isInitialized) {
|
||||||
|
loadPlaylists()
|
||||||
|
isInitialized = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getPlaylists(): List<LocalPlaylist> = _playlists.value
|
||||||
|
|
||||||
|
fun getPlaylist(name: String): LocalPlaylist? = _playlists.value.find { it.name == name }
|
||||||
|
|
||||||
|
suspend fun createPlaylist(name: String, description: String? = null): Result<LocalPlaylist> {
|
||||||
|
return runCatching {
|
||||||
|
if (name.isBlank()) {
|
||||||
|
throw IllegalArgumentException(getString(Res.string.library_playlist_name_empty))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_playlists.value.any { it.name == name }) {
|
||||||
|
throw IllegalArgumentException(getString(Res.string.library_playlist_name_exists))
|
||||||
|
}
|
||||||
|
|
||||||
|
val playlist = LocalPlaylist(
|
||||||
|
id = java.util.UUID.randomUUID().toString(),
|
||||||
|
name = name,
|
||||||
|
description = description,
|
||||||
|
createdAt = System.currentTimeMillis(),
|
||||||
|
updatedAt = System.currentTimeMillis(),
|
||||||
|
trackPaths = emptyList()
|
||||||
|
)
|
||||||
|
|
||||||
|
_playlists.update { it + playlist }
|
||||||
|
savePlaylist(playlist)
|
||||||
|
playlist
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun updatePlaylist(playlist: LocalPlaylist): Result<Unit> {
|
||||||
|
return runCatching {
|
||||||
|
val index = _playlists.value.indexOfFirst { it.id == playlist.id }
|
||||||
|
if (index < 0) {
|
||||||
|
throw IllegalArgumentException(getString(Res.string.library_playlist_not_found))
|
||||||
|
}
|
||||||
|
|
||||||
|
val updated = playlist.copy(
|
||||||
|
updatedAt = System.currentTimeMillis()
|
||||||
|
)
|
||||||
|
|
||||||
|
_playlists.update { list ->
|
||||||
|
list.set(index, updated)
|
||||||
|
}
|
||||||
|
savePlaylist(updated)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun deletePlaylist(playlistId: String): Result<Unit> {
|
||||||
|
return runCatching {
|
||||||
|
val playlist = _playlists.value.find { it.id == playlistId }
|
||||||
|
if (playlist == null) {
|
||||||
|
throw IllegalArgumentException(getString(Res.string.library_playlist_not_found))
|
||||||
|
}
|
||||||
|
|
||||||
|
val file = getPlaylistFile(playlist.name)
|
||||||
|
file.delete()
|
||||||
|
|
||||||
|
_playlists.update { it.filter { it.id != playlistId } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun addTrack(playlistId: String, track: LocalMediaTrack): Result<Unit> {
|
||||||
|
return runCatching {
|
||||||
|
val playlist = _playlists.value.find { it.id == playlistId }
|
||||||
|
?: throw IllegalArgumentException(getString(Res.string.library_playlist_not_found))
|
||||||
|
|
||||||
|
val trackPath = track.path
|
||||||
|
if (playlist.trackPaths.contains(trackPath)) {
|
||||||
|
return // Already in playlist
|
||||||
|
}
|
||||||
|
|
||||||
|
val updated = playlist.copy(
|
||||||
|
trackPaths = playlist.trackPaths + trackPath,
|
||||||
|
updatedAt = System.currentTimeMillis()
|
||||||
|
)
|
||||||
|
|
||||||
|
_playlists.update { list ->
|
||||||
|
list.map { if (it.id == playlistId) updated else it }
|
||||||
|
}
|
||||||
|
savePlaylist(updated)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun addTracks(playlistId: String, tracks: List<LocalMediaTrack>): Result<Unit> {
|
||||||
|
return runCatching {
|
||||||
|
val playlist = _playlists.value.find { it.id == playlistId }
|
||||||
|
?: throw IllegalArgumentException(getString(Res.string.library_playlist_not_found))
|
||||||
|
|
||||||
|
val newTrackPaths = tracks.map { it.path }.filterNot { it in playlist.trackPaths }
|
||||||
|
if (newTrackPaths.isEmpty()) return
|
||||||
|
|
||||||
|
val updated = playlist.copy(
|
||||||
|
trackPaths = playlist.trackPaths + newTrackPaths,
|
||||||
|
updatedAt = System.currentTimeMillis()
|
||||||
|
)
|
||||||
|
|
||||||
|
_playlists.update { list ->
|
||||||
|
list.map { if (it.id == playlistId) updated else it }
|
||||||
|
}
|
||||||
|
savePlaylist(updated)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun removeTrack(playlistId: String, trackPath: String): Result<Unit> {
|
||||||
|
return runCatching {
|
||||||
|
val playlist = _playlists.value.find { it.id == playlistId }
|
||||||
|
?: throw IllegalArgumentException(getString(Res.string.library_playlist_not_found))
|
||||||
|
|
||||||
|
if (!playlist.trackPaths.contains(trackPath)) return
|
||||||
|
|
||||||
|
val updated = playlist.copy(
|
||||||
|
trackPaths = playlist.trackPaths.filter { it != trackPath },
|
||||||
|
updatedAt = System.currentTimeMillis()
|
||||||
|
)
|
||||||
|
|
||||||
|
_playlists.update { list ->
|
||||||
|
list.map { if (it.id == playlistId) updated else it }
|
||||||
|
}
|
||||||
|
savePlaylist(updated)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun reorderTracks(playlistId: String, fromIndex: Int, toIndex: Int): Result<Unit> {
|
||||||
|
return runCatching {
|
||||||
|
val playlist = _playlists.value.find { it.id == playlistId }
|
||||||
|
?: throw IllegalArgumentException(getString(Res.string.library_playlist_not_found))
|
||||||
|
|
||||||
|
if (fromIndex < 0 || fromIndex >= playlist.trackPaths.size ||
|
||||||
|
toIndex < 0 || toIndex >= playlist.trackPaths.size) {
|
||||||
|
throw IndexOutOfBoundsException()
|
||||||
|
}
|
||||||
|
|
||||||
|
val newTrackPaths = playlist.trackPaths.toMutableList()
|
||||||
|
val trackPath = newTrackPaths.removeAt(fromIndex)
|
||||||
|
newTrackPaths.add(toIndex, trackPath)
|
||||||
|
|
||||||
|
val updated = playlist.copy(
|
||||||
|
trackPaths = newTrackPaths,
|
||||||
|
updatedAt = System.currentTimeMillis()
|
||||||
|
)
|
||||||
|
|
||||||
|
_playlists.update { list ->
|
||||||
|
list.map { if (it.id == playlistId) updated else it }
|
||||||
|
}
|
||||||
|
savePlaylist(updated)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun savePlaylist(playlist: LocalPlaylist) {
|
||||||
|
scope.launch {
|
||||||
|
val file = getPlaylistFile(playlist.name)
|
||||||
|
val m3u8Content = buildM3u8Content(playlist)
|
||||||
|
file.writeText(m3u8Content, StandardCharsets.UTF_8)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildM3u8Content(playlist: LocalPlaylist): String {
|
||||||
|
val sb = StringBuilder()
|
||||||
|
sb.append("#EXTM3U\n")
|
||||||
|
sb.append("#PLAYLIST:${playlist.name}\n")
|
||||||
|
if (playlist.description != null) {
|
||||||
|
sb.append("#DESCRIPTION:${playlist.description}\n")
|
||||||
|
}
|
||||||
|
sb.append("#EXTENC:UTF-8\n")
|
||||||
|
|
||||||
|
for (trackPath in playlist.trackPaths) {
|
||||||
|
val trackName = File(trackPath).nameWithoutExtension
|
||||||
|
sb.append("#EXTINF:-1,$trackName\n")
|
||||||
|
sb.append("$trackPath\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
return sb.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loadPlaylists() {
|
||||||
|
scope.launch {
|
||||||
|
val files = playlistsDirFile.listFiles { it.extension == "m3u8" } ?: emptyArray()
|
||||||
|
val playlists = files.mapNotNull { file ->
|
||||||
|
parseM3u8(file).copy(filePath = file.absolutePath)
|
||||||
|
}
|
||||||
|
_playlists.value = playlists
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseM3u8(file: File): LocalPlaylist {
|
||||||
|
val lines = file.readText(StandardCharsets.UTF_8).splitLines()
|
||||||
|
var name = file.nameWithoutExtension
|
||||||
|
var description: String? = null
|
||||||
|
var trackPaths = mutableListOf<String>()
|
||||||
|
|
||||||
|
for (line in lines) {
|
||||||
|
when {
|
||||||
|
line.startsWith("#PLAYLIST:") -> name = line.substringAfter("#PLAYLIST:").trim()
|
||||||
|
line.startsWith("#DESCRIPTION:") -> description = line.substringAfter("#DESCRIPTION:").trim()
|
||||||
|
line.startsWith("#EXTINF:") -> {
|
||||||
|
// Format: #EXTINF:-1,trackName
|
||||||
|
// Next line is the track path
|
||||||
|
}
|
||||||
|
!line.startsWith("#") && line.isNotBlank() -> {
|
||||||
|
// This is a track path line
|
||||||
|
val trackPath = line.trim()
|
||||||
|
if (trackPath.isNotBlank()) trackPaths.add(trackPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return LocalPlaylist(
|
||||||
|
id = file.nameWithoutExtension,
|
||||||
|
name = name,
|
||||||
|
description = description,
|
||||||
|
createdAt = file.lastModified(),
|
||||||
|
updatedAt = file.lastModified(),
|
||||||
|
trackPaths = trackPaths,
|
||||||
|
filePath = file.absolutePath
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class LocalPlaylist(
|
||||||
|
val id: String,
|
||||||
|
val name: String,
|
||||||
|
val description: String? = null,
|
||||||
|
val createdAt: Long,
|
||||||
|
val updatedAt: Long,
|
||||||
|
val trackPaths: List<String> = emptyList(),
|
||||||
|
val filePath: String? = null
|
||||||
|
)
|
||||||
@ -0,0 +1,298 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.spotube.spotube.modules.library.playlist
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import org.spotube.spotube.core.di.injectLogger
|
||||||
|
import org.spotube.spotube.modules.library.local_tracks.LibraryLocalTracksViewModel
|
||||||
|
import org.spotube.spotube.modules.library.local_tracks.media.LocalMediaTrack
|
||||||
|
import org.spotube.spotube.modules.settings.SettingsProvider
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import kotlinx.coroutines.flow.stateIn
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
|
import org.jetbrains.compose.resources.getString
|
||||||
|
import spotube.composeapp.generated.resources.Res
|
||||||
|
import io.github.vinceglb.filekit.PlatformFile
|
||||||
|
import kotlinx.coroutines.tasks.await
|
||||||
|
import io.github.vinceglb.filekit.writeText
|
||||||
|
|
||||||
|
sealed interface LocalPlaylistScreenState {
|
||||||
|
data class Data(
|
||||||
|
val playlists: List<LocalPlaylist> = emptyList(),
|
||||||
|
val query: String = "",
|
||||||
|
val selectedPlaylist: LocalPlaylist? = null,
|
||||||
|
val showCreateDialog: Boolean = false,
|
||||||
|
val showEditDialog: Boolean = false,
|
||||||
|
val showAddTracksDialog: Boolean = false,
|
||||||
|
val editingPlaylist: LocalPlaylist? = null,
|
||||||
|
val isLoading: Boolean = false,
|
||||||
|
val error: String? = null
|
||||||
|
) : LocalPlaylistScreenState
|
||||||
|
|
||||||
|
data object Loading : LocalPlaylistScreenState
|
||||||
|
data class Error(val message: String) : LocalPlaylistScreenState
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
class LocalPlaylistViewModel(
|
||||||
|
private val repository: LocalPlaylistRepository,
|
||||||
|
private val localTracksViewModel: LibraryLocalTracksViewModel,
|
||||||
|
private val settingsProvider: SettingsProvider
|
||||||
|
) : ViewModel() {
|
||||||
|
private val logger by injectLogger<LocalPlaylistViewModel>()
|
||||||
|
|
||||||
|
private val _uiState = MutableStateFlow<LocalPlaylistScreenState>(LocalPlaylistScreenState.Loading)
|
||||||
|
val uiState: StateFlow<LocalPlaylistScreenState> = _uiState.asStateFlow()
|
||||||
|
|
||||||
|
private val _query = mutableStateOf("")
|
||||||
|
var query: String
|
||||||
|
get() = _query.value
|
||||||
|
set(value) { _query.value = value }
|
||||||
|
|
||||||
|
private val _showCreateDialog = mutableStateOf(false)
|
||||||
|
var showCreateDialog: Boolean
|
||||||
|
get() = _showCreateDialog.value
|
||||||
|
set(value) { _showCreateDialog.value = value }
|
||||||
|
|
||||||
|
private val _showEditDialog = mutableStateOf(false)
|
||||||
|
var showEditDialog: Boolean
|
||||||
|
get() = _showEditDialog.value
|
||||||
|
set(value) { _showEditDialog.value = value }
|
||||||
|
|
||||||
|
private val _showAddTracksDialog = mutableStateOf(false)
|
||||||
|
var showAddTracksDialog: Boolean
|
||||||
|
get() = _showAddTracksDialog.value
|
||||||
|
set(value) { _showAddTracksDialog.value = value }
|
||||||
|
|
||||||
|
private val _editingPlaylist = MutableStateFlow<LocalPlaylist?>(null)
|
||||||
|
val editingPlaylist: StateFlow<LocalPlaylist?> = _editingPlaylist.asStateFlow()
|
||||||
|
|
||||||
|
private val _showAddTracksForPlaylist = MutableStateFlow<LocalPlaylist?>(null)
|
||||||
|
val showAddTracksForPlaylist: StateFlow<LocalPlaylist?> = _showAddTracksForPlaylist.asStateFlow()
|
||||||
|
|
||||||
|
init {
|
||||||
|
viewModelScope.launch {
|
||||||
|
repository.playlists
|
||||||
|
.combine(localTracksViewModel.state) { playlists, localTracksState ->
|
||||||
|
LocalPlaylistScreenState.Data(
|
||||||
|
playlists = playlists,
|
||||||
|
query = "",
|
||||||
|
selectedPlaylist = null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.stateIn(
|
||||||
|
viewModelScope,
|
||||||
|
SharingStarted.WhileSubscribed(5_000),
|
||||||
|
LocalPlaylistScreenState.Loading
|
||||||
|
)
|
||||||
|
.collect { state ->
|
||||||
|
_uiState.value = state
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onQueryChange(query: String) {
|
||||||
|
_uiState.update { state ->
|
||||||
|
if (state is LocalPlaylistScreenState.Data) {
|
||||||
|
state.copy(query = query)
|
||||||
|
} else state
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun showCreatePlaylistDialog() {
|
||||||
|
_showCreateDialog.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
fun dismissCreatePlaylistDialog() {
|
||||||
|
_showCreateDialog.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
fun createPlaylist(name: String, description: String? = null) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_uiState.update { it.copy(isLoading = true) }
|
||||||
|
repository.createPlaylist(name, description)
|
||||||
|
.onSuccess { playlist ->
|
||||||
|
_uiState.update { state ->
|
||||||
|
if (state is LocalPlaylistScreenState.Data) {
|
||||||
|
state.copy(playlists = state.playlists + playlist, isLoading = false)
|
||||||
|
} else state
|
||||||
|
}
|
||||||
|
dismissCreatePlaylistDialog()
|
||||||
|
}
|
||||||
|
.onFailure { e ->
|
||||||
|
_uiState.update { state ->
|
||||||
|
if (state is LocalPlaylistScreenState.Data) {
|
||||||
|
state.copy(isLoading = false, error = e.message)
|
||||||
|
} else LocalPlaylistScreenState.Error(e.message ?: getString(Res.string.common_unknown_error))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun showEditPlaylistDialog(playlist: LocalPlaylist) {
|
||||||
|
_editingPlaylist.value = playlist
|
||||||
|
_showEditDialog.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
fun dismissEditPlaylistDialog() {
|
||||||
|
_showEditDialog.value = false
|
||||||
|
_editingPlaylist.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updatePlaylist(playlist: LocalPlaylist) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
repository.updatePlaylist(playlist)
|
||||||
|
.onSuccess {
|
||||||
|
_uiState.update { state ->
|
||||||
|
if (state is LocalPlaylistScreenState.Data) {
|
||||||
|
val updatedPlaylists = state.playlists.map {
|
||||||
|
if (it.id == playlist.id) playlist else it
|
||||||
|
}
|
||||||
|
state.copy(playlists = updatedPlaylists, editingPlaylist = null)
|
||||||
|
} else state
|
||||||
|
}
|
||||||
|
dismissEditPlaylistDialog()
|
||||||
|
}
|
||||||
|
.onFailure { e ->
|
||||||
|
_uiState.update { state ->
|
||||||
|
if (state is LocalPlaylistScreenState.Data) {
|
||||||
|
state.copy(error = e.message)
|
||||||
|
} else state
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun deletePlaylist(playlistId: String) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
repository.deletePlaylist(playlistId)
|
||||||
|
.onSuccess {
|
||||||
|
_uiState.update { state ->
|
||||||
|
if (state is LocalPlaylistScreenState.Data) {
|
||||||
|
state.copy(playlists = state.playlists.filter { it.id != playlistId })
|
||||||
|
} else state
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onFailure { e ->
|
||||||
|
_uiState.update { state ->
|
||||||
|
if (state is LocalPlaylistScreenState.Data) {
|
||||||
|
state.copy(error = e.message)
|
||||||
|
} else state
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun showAddTracksDialog(playlist: LocalPlaylist) {
|
||||||
|
_showAddTracksForPlaylist.value = playlist
|
||||||
|
_showAddTracksDialog.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
fun dismissAddTracksDialog() {
|
||||||
|
_showAddTracksDialog.value = false
|
||||||
|
_showAddTracksForPlaylist.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
fun addTracks(playlist: LocalPlaylist, tracks: List<LocalMediaTrack>) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
repository.addTracks(playlist.id, tracks)
|
||||||
|
.onSuccess {
|
||||||
|
_uiState.update { state ->
|
||||||
|
if (state is LocalPlaylistScreenState.Data) {
|
||||||
|
val updatedPlaylists = state.playlists.map {
|
||||||
|
if (it.id == playlist.id)
|
||||||
|
it.copy(trackPaths = it.trackPaths + tracks.map { it.path }.filterNot { it in it.trackPaths })
|
||||||
|
else it
|
||||||
|
}
|
||||||
|
state.copy(playlists = updatedPlaylists)
|
||||||
|
} else state
|
||||||
|
}
|
||||||
|
dismissAddTracksDialog()
|
||||||
|
}
|
||||||
|
.onFailure { e ->
|
||||||
|
_uiState.update { state ->
|
||||||
|
if (state is LocalPlaylistScreenState.Data) {
|
||||||
|
state.copy(error = e.message)
|
||||||
|
} else state
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun removeTrack(playlistId: String, trackPath: String) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
repository.removeTrack(playlistId, trackPath)
|
||||||
|
.onSuccess {
|
||||||
|
_uiState.update { state ->
|
||||||
|
if (state is LocalPlaylistScreenState.Data) {
|
||||||
|
val updatedPlaylists = state.playlists.map {
|
||||||
|
if (it.id == playlistId)
|
||||||
|
it.copy(trackPaths = it.trackPaths.filter { it != trackPath })
|
||||||
|
else it
|
||||||
|
}
|
||||||
|
state.copy(playlists = updatedPlaylists)
|
||||||
|
} else state
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onFailure { e ->
|
||||||
|
_uiState.update { state ->
|
||||||
|
if (state is LocalPlaylistScreenState.Data) {
|
||||||
|
state.copy(error = e.message)
|
||||||
|
} else state
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun reorderTracks(playlistId: String, fromIndex: Int, toIndex: Int) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
repository.reorderTracks(playlistId, fromIndex, toIndex)
|
||||||
|
.onSuccess {
|
||||||
|
_uiState.update { state ->
|
||||||
|
if (state is LocalPlaylistScreenState.Data) {
|
||||||
|
val updatedPlaylists = state.playlists.map {
|
||||||
|
if (it.id == playlistId) {
|
||||||
|
val newTrackPaths = it.trackPaths.toMutableList()
|
||||||
|
val trackPath = newTrackPaths.removeAt(fromIndex)
|
||||||
|
newTrackPaths.add(toIndex, trackPath)
|
||||||
|
it.copy(trackPaths = newTrackPaths)
|
||||||
|
} else it
|
||||||
|
}
|
||||||
|
state.copy(playlists = updatedPlaylists)
|
||||||
|
} else state
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onFailure { e ->
|
||||||
|
_uiState.update { state ->
|
||||||
|
if (state is LocalPlaylistScreenState.Data) {
|
||||||
|
state.copy(error = e.message)
|
||||||
|
} else state
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user