mirror of
https://github.com/KRTirtho/spotube.git
synced 2026-11-04 10:13:59 +00:00
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.
This commit is contained in:
parent
6ea3641200
commit
73bac7b04c
@ -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>
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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,6 +158,80 @@ fun LibraryPlaylistsScreen(
|
||||
stringResource(Res.string.library_local_playlists_section),
|
||||
style = com.composeunstyled.Typography.h6
|
||||
)
|
||||
// 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),
|
||||
@ -133,6 +240,8 @@ fun LibraryPlaylistsScreen(
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
itemContent = { item, list ->
|
||||
LocalPlaylistCard(
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user