diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml
index 6bcab2be..1b63ab82 100644
--- a/composeApp/src/commonMain/composeResources/values/strings.xml
+++ b/composeApp/src/commonMain/composeResources/values/strings.xml
@@ -46,6 +46,10 @@
No saved playlists
No playlists match “%1$s”
No local playlists yet
+ Syncing…
+ Synced
+ Sync failed
+ Sync now
Playlist name cannot be empty
A playlist with this name already exists
Playlist not found
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 038d70e7..223cb185 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
@@ -141,7 +141,7 @@ val sharedModules = module {
singleOf(::LocalMediaCacheRepository)
singleOf(::LocalMediaFoldersConfig)
singleOf(::LocalMediaLibraryCoordinator)
- singleOf(::LocalPlaylistRepository) // Local playlist repository
+ single { LocalPlaylistRepository(get(), get(), get()) } // Local playlist repository with sync
viewModelOf(::LibraryPlaylistsViewModel)
viewModelOf(::LocalPlaylistViewModel) // Local playlist view model
viewModelOf(::LibraryAlbumsViewModel)
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 b250240d..fa931ed1 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
@@ -26,6 +26,38 @@ import org.jetbrains.compose.resources.stringResource
import org.koin.compose.viewmodel.koinViewModel
import spotube.composeapp.generated.resources.*
+import org.spotube.millennium.composables.MillenniumIconButton
+import org.spotube.millennium.composables.MillenniumIconButtonStyle
+import org.spotube.millennium.composables.MillenniumButton
+import org.spotube.millennium.composables.MillenniumButtonStyle
+import org.spotube.millennium.composables.MillenniumIcon
+import org.spotube.millennium.composables.MillenniumSpokeSpinner
+import org.spotube.millennium.theme.MillenniumTheme
+import org.spotube.spotube.modules.library.playlist.LocalPlaylistRepository
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.size
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.setValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import com.composeunstyled.Text
+import com.composeunstyled.Typography
+import org.jetbrains.compose.resources.stringResource
+import org.koin.compose.viewmodel.koinViewModel
+import spotube.composeapp.generated.resources.Res
+import spotube.composeapp.generated.resources.library_local_playlists_section
+
@Composable
fun LibraryPlaylistsScreen(
modifier: Modifier = Modifier,
@@ -54,6 +86,7 @@ fun LibraryPlaylistsScreen(
val allPlaylists = spotifyPlaylists + localPlaylists
val hasPlaylists = allPlaylists.isNotEmpty()
val isLoading = current is LibraryPlaylistsState.Loading || localState is LocalPlaylistScreenState.Loading
+ val syncState = localData?.syncState ?: LocalPlaylistRepository.SyncState.Idle
if (!hasPlaylists && !isLoading) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
@@ -125,12 +158,88 @@ fun LibraryPlaylistsScreen(
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
- )
+ // Sync button and status
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(top = 4.dp),
+ contentAlignment = Alignment.CenterEnd
+ ) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.End,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ // Sync status indicator
+ when (syncState) {
+ is LocalPlaylistRepository.SyncState.Syncing -> {
+ MillenniumSpokeSpinner(size = 16.dp)
+ Text(
+ stringResource(Res.string.library_playlist_syncing),
+ style = Typography.bodySmall,
+ color = Color.Gray
+ )
+ }
+ is LocalPlaylistRepository.SyncState.Success -> {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(4.dp)
+ ) {
+ MillenniumIcon(
+ Iconsax.IconsaxTickSquare,
+ contentDescription = null,
+ tint = Color.Green,
+ modifier = Modifier.size(16.dp)
+ )
+ Text(
+ stringResource(Res.string.library_playlist_synced),
+ style = Typography.bodySmall,
+ color = Color.Green
+ )
+ }
+ }
+ is LocalPlaylistRepository.SyncState.Error -> {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(4.dp)
+ ) {
+ MillenniumIcon(
+ Iconsax.IconsaxWarning,
+ contentDescription = null,
+ tint = Color.Red,
+ modifier = Modifier.size(16.dp)
+ )
+ Text(
+ stringResource(Res.string.library_playlist_sync_error),
+ style = Typography.bodySmall,
+ color = Color.Red
+ )
+ }
+ }
+ else -> {
+ // Idle - show sync button
+ MillenniumIconButton(
+ onClick = { localPlaylistViewModel.syncNow() },
+ enabled = true,
+ style = MillenniumIconButtonStyle.Quiet
+ ) {
+ MillenniumIcon(
+ Iconsax.IconsaxRefreshRight,
+ contentDescription = stringResource(Res.string.library_playlist_sync_now),
+ modifier = Modifier.size(20.dp)
+ )
+ }
+ }
+ }
+ // Create playlist button
+ MillenniumIconButton(
+ icon = Iconsax.IconsaxAddSquare,
+ contentDescription = stringResource(Res.string.library_action_create_playlist),
+ onClick = { localPlaylistViewModel.showCreatePlaylistDialog() },
+ style = MillenniumIconButtonStyle.Quiet
+ )
+ }
+ }
}
}
},
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
index 5ca556e1..42928840 100644
--- 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
@@ -17,6 +17,7 @@
package org.spotube.spotube.modules.library.playlist
+import org.spotube.spotube.core.jam.JamRoomClient
import org.spotube.spotube.modules.library.local_tracks.media.LocalMediaTrack
import org.spotube.spotube.modules.settings.SettingsRepository
import kotlinx.coroutines.CoroutineScope
@@ -42,9 +43,41 @@ import org.koin.core.component.KoinComponent
* Local playlists are stored as M3U8 files in the configured playlists directory.
* They contain local tracks (from LocalMediaTrack) referenced by their file path.
*/
+package org.spotube.spotube.modules.library.playlist
+
+import org.spotube.spotube.core.jam.JamRoomClient
+import org.spotube.spotube.core.jam.JamRoomService
+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.
+ * Supports cross-device sync via Jam (MQTT) mechanism.
+ */
@OptIn(ExperimentalCoroutinesApi::class)
class LocalPlaylistRepository(
- private val settingsRepository: SettingsRepository
+ private val settingsRepository: SettingsRepository,
+ private val jamRoomClient: JamRoomClient,
+ private val jamRoomService: JamRoomService
) : KoinComponent {
private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
@@ -56,10 +89,22 @@ class LocalPlaylistRepository(
private val _playlists = MutableStateFlow>(emptyList())
val playlists: StateFlow> = _playlists.asStateFlow()
+ private val _syncState = MutableStateFlow(SyncState.Idle)
+ val syncState: StateFlow = _syncState.asStateFlow()
+
private var isInitialized = false
+ private var currentSyncJob: kotlinx.coroutines.Job? = null
init {
loadPlaylists()
+ observeJamSync()
+ }
+
+ sealed interface SyncState {
+ data object Idle : SyncState
+ data object Syncing : SyncState
+ data class Success(val timestamp: Long) : SyncState
+ data class Error(val message: String) : SyncState
}
private val playlistsDir: String
@@ -107,6 +152,7 @@ class LocalPlaylistRepository(
_playlists.update { it + playlist }
savePlaylist(playlist)
+ broadcastPlaylistChange(PlaylistChange.Created(playlist))
playlist
}
}
@@ -126,6 +172,7 @@ class LocalPlaylistRepository(
list.set(index, updated)
}
savePlaylist(updated)
+ broadcastPlaylistChange(PlaylistChange.Updated(updated))
}
}
@@ -140,6 +187,7 @@ class LocalPlaylistRepository(
file.delete()
_playlists.update { it.filter { it.id != playlistId } }
+ broadcastPlaylistChange(PlaylistChange.Deleted(playlistId))
}
}
@@ -162,6 +210,7 @@ class LocalPlaylistRepository(
list.map { if (it.id == playlistId) updated else it }
}
savePlaylist(updated)
+ broadcastPlaylistChange(PlaylistChange.TrackAdded(playlistId, track.path))
}
}
@@ -182,6 +231,7 @@ class LocalPlaylistRepository(
list.map { if (it.id == playlistId) updated else it }
}
savePlaylist(updated)
+ broadcastPlaylistChange(PlaylistChange.TracksAdded(playlistId, newTrackPaths))
}
}
@@ -201,6 +251,7 @@ class LocalPlaylistRepository(
list.map { if (it.id == playlistId) updated else it }
}
savePlaylist(updated)
+ broadcastPlaylistChange(PlaylistChange.TrackRemoved(playlistId, trackPath))
}
}
@@ -227,6 +278,7 @@ class LocalPlaylistRepository(
list.map { if (it.id == playlistId) updated else it }
}
savePlaylist(updated)
+ broadcastPlaylistChange(PlaylistChange.Reordered(playlistId, newTrackPaths))
}
}
@@ -298,6 +350,136 @@ class LocalPlaylistRepository(
filePath = file.absolutePath
)
}
+
+ // ============ Sync via Jam (MQTT) ============
+
+ sealed interface PlaylistChange {
+ @Serializable
+ data class Created(val playlist: LocalPlaylist) : PlaylistChange
+ @Serializable
+ data class Updated(val playlist: LocalPlaylist) : PlaylistChange
+ @Serializable
+ data class Deleted(val playlistId: String) : PlaylistChange
+ @Serializable
+ data class TrackAdded(val playlistId: String, val trackPath: String) : PlaylistChange
+ @Serializable
+ data class TracksAdded(val playlistId: String, val trackPaths: List) : PlaylistChange
+ @Serializable
+ data class TrackRemoved(val playlistId: String, val trackPath: String) : PlaylistChange
+ @Serializable
+ data class Reordered(val playlistId: String, val trackPaths: List) : PlaylistChange
+ }
+
+ private fun broadcastPlaylistChange(change: PlaylistChange) {
+ if (!jamRoomService.isConnected.value) return
+ if (jamRoomService.role.value != org.spotube.spotube.core.jam.JamRole.Host) return
+
+ scope.launch {
+ jamRoomClient.publishCommand(
+ org.spotube.spotube.core.jam.JamMessage.PlaybackCommand(
+ org.spotube.spotube.core.jam.PlaybackCmd.Custom(
+ type = "playlist_sync",
+ payload = json.encodeToString(PlaylistChange.serializer(), change)
+ )
+ )
+ )
+ }
+ }
+
+ private fun observeJamSync() {
+ scope.launch {
+ jamRoomClient.commands
+ .filter { it is org.spotube.spotube.core.jam.JamMessage.PlaybackCommand }
+ .map { it as org.spotube.spotube.core.jam.JamMessage.PlaybackCommand }
+ .filter { it.command is org.spotube.spotube.core.jam.PlaybackCmd.Custom }
+ .map { (it.command as org.spotube.spotube.core.jam.PlaybackCmd.Custom) }
+ .filter { it.type == "playlist_sync" }
+ .onEach { cmd ->
+ try {
+ val change = json.decodeFromString(PlaylistChange.serializer(), cmd.payload)
+ applyRemoteChange(change)
+ } catch (e: Exception) {
+ // Ignore parse errors
+ }
+ }
+ .launchIn(scope)
+ }
+ }
+
+ private fun applyRemoteChange(change: PlaylistChange) {
+ when (change) {
+ is PlaylistChange.Created -> {
+ _playlists.update { it + change.playlist }
+ }
+ is PlaylistChange.Updated -> {
+ _playlists.update { list ->
+ val index = list.indexOfFirst { it.id == change.playlist.id }
+ if (index >= 0) list.set(index, change.playlist) else list
+ }
+ }
+ is PlaylistChange.Deleted -> {
+ _playlists.update { it.filter { it.id != change.playlistId } }
+ getPlaylistFile(change.playlistId).delete()
+ }
+ is PlaylistChange.TrackAdded -> {
+ _playlists.update { list ->
+ list.map { pl ->
+ if (pl.id == change.playlistId && !pl.trackPaths.contains(change.trackPath)) {
+ pl.copy(trackPaths = pl.trackPaths + change.trackPath, updatedAt = System.currentTimeMillis())
+ } else pl
+ }
+ }
+ }
+ is PlaylistChange.TracksAdded -> {
+ _playlists.update { list ->
+ list.map { pl ->
+ if (pl.id == change.playlistId) {
+ val newPaths = change.trackPaths.filterNot { it in pl.trackPaths }
+ if (newPaths.isEmpty()) pl else pl.copy(trackPaths = pl.trackPaths + newPaths, updatedAt = System.currentTimeMillis())
+ } else pl
+ }
+ }
+ }
+ is PlaylistChange.TrackRemoved -> {
+ _playlists.update { list ->
+ list.map { pl ->
+ if (pl.id == change.playlistId) {
+ pl.copy(trackPaths = pl.trackPaths.filter { it != change.trackPath }, updatedAt = System.currentTimeMillis())
+ } else pl
+ }
+ }
+ }
+ is PlaylistChange.Reordered -> {
+ _playlists.update { list ->
+ list.map { pl ->
+ if (pl.id == change.playlistId) {
+ pl.copy(trackPaths = change.trackPaths, updatedAt = System.currentTimeMillis())
+ } else pl
+ }
+ }
+ }
+ }
+ }
+
+ // ============ Manual Sync Trigger ============
+
+ suspend fun syncNow(): Result {
+ return runCatching {
+ _syncState.value = SyncState.Syncing
+ try {
+ // Broadcast full playlist state to other devices
+ for (playlist in _playlists.value) {
+ broadcastPlaylistChange(PlaylistChange.Created(playlist))
+ }
+ _syncState.value = SyncState.Success(System.currentTimeMillis())
+ } catch (e: Exception) {
+ _syncState.value = SyncState.Error(e.message ?: "Sync failed")
+ throw e
+ }
+ }
+ }
+
+ fun getSyncState(): SyncState = _syncState.value
}
@Serializable
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
index ca718f95..3867bbc4 100644
--- 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
@@ -34,6 +34,7 @@ import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.MutableStateFlow
import org.jetbrains.compose.resources.getString
import spotube.composeapp.generated.resources.Res
import io.github.vinceglb.filekit.PlatformFile
@@ -50,7 +51,8 @@ sealed interface LocalPlaylistScreenState {
val showAddTracksDialog: Boolean = false,
val editingPlaylist: LocalPlaylist? = null,
val isLoading: Boolean = false,
- val error: String? = null
+ val error: String? = null,
+ val syncState: LocalPlaylistRepository.SyncState = LocalPlaylistRepository.SyncState.Idle
) : LocalPlaylistScreenState
data object Loading : LocalPlaylistScreenState
@@ -97,11 +99,12 @@ class LocalPlaylistViewModel(
init {
viewModelScope.launch {
repository.playlists
- .combine(localTracksViewModel.state) { playlists, localTracksState ->
+ .combine(localTracksViewModel.state, repository.syncState) { playlists, localTracksState, syncState ->
LocalPlaylistScreenState.Data(
playlists = playlists,
query = "",
- selectedPlaylist = null
+ selectedPlaylist = null,
+ syncState = syncState
)
}
.stateIn(
@@ -295,4 +298,29 @@ class LocalPlaylistViewModel(
}
}
}
+
+ fun syncNow() {
+ viewModelScope.launch {
+ _uiState.update { state ->
+ if (state is LocalPlaylistScreenState.Data) {
+ state.copy(syncState = LocalPlaylistRepository.SyncState.Syncing)
+ } else state
+ }
+ repository.syncNow()
+ .onSuccess {
+ _uiState.update { state ->
+ if (state is LocalPlaylistScreenState.Data) {
+ state.copy(syncState = LocalPlaylistRepository.SyncState.Success(System.currentTimeMillis()))
+ } else state
+ }
+ }
+ .onFailure { e ->
+ _uiState.update { state ->
+ if (state is LocalPlaylistScreenState.Data) {
+ state.copy(syncState = LocalPlaylistRepository.SyncState.Error(e.message ?: "Sync failed"))
+ } else state
+ }
+ }
+ }
+ }
}
\ No newline at end of file