From 73bac7b04c2442f4641f7f24f5146023748b7216 Mon Sep 17 00:00:00 2001 From: thumb2086 Date: Sat, 31 Oct 2026 11:59:52 +0800 Subject: [PATCH] feat(library): add local playlist cross-device sync via Jam - LocalPlaylistRepository: Add sync via Jam (MQTT) mechanism - SyncState sealed class (Idle, Syncing, Success, Error) - PlaylistChange sealed class (Created, Updated, Deleted, TrackAdded, etc.) - broadcastPlaylistChange() sends changes via Jam MQTT - observeJamSync() listens for remote changes - applyRemoteChange() applies remote changes locally - syncNow() triggers full sync broadcast - LocalPlaylistViewModel: Add syncNow() and syncState to UI - LibraryPlaylistsScreen: Add sync button and status indicator in Local Playlists header - String resources: syncing, synced, sync error, sync now Uses existing Jam MQTT infrastructure for cross-device sync. Only Host can broadcast changes; Guests receive and apply. --- .../composeResources/values/strings.xml | 4 + .../org/spotube/spotube/core/di/Modules.kt | 2 +- .../playlist/LibraryPlaylistsScreen.kt | 121 +++++++++++- .../playlist/LocalPlaylistRepository.kt | 184 +++++++++++++++++- .../playlist/LocalPlaylistViewModel.kt | 34 +++- 5 files changed, 334 insertions(+), 11 deletions(-) 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