Compare commits

...

2 Commits

Author SHA1 Message Date
thumb2086
00d6be2084
Merge 73bac7b04c into 35700920b4 2026-10-31 10:00:02 +06:00
thumb2086
73bac7b04c 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.
2026-10-31 11:59:52 +08:00
5 changed files with 334 additions and 11 deletions

View File

@ -46,6 +46,10 @@
<string name="library_playlists_empty">No saved playlists</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_syncing">Syncing…</string>
<string name="library_playlist_synced">Synced</string>
<string name="library_playlist_sync_error">Sync failed</string>
<string name="library_playlist_sync_now">Sync now</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>

View File

@ -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)

View File

@ -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
)
}
}
}
}
},

View File

@ -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<List<LocalPlaylist>>(emptyList())
val playlists: StateFlow<List<LocalPlaylist>> = _playlists.asStateFlow()
private val _syncState = MutableStateFlow<SyncState>(SyncState.Idle)
val syncState: StateFlow<SyncState> = _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<String>) : PlaylistChange
@Serializable
data class TrackRemoved(val playlistId: String, val trackPath: String) : PlaylistChange
@Serializable
data class Reordered(val playlistId: String, val trackPaths: List<String>) : 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<Unit> {
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

View File

@ -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
}
}
}
}
}