mirror of
https://github.com/KRTirtho/spotube.git
synced 2026-11-04 10:13:59 +00:00
Compare commits
2 Commits
060fb631dd
...
00d6be2084
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
00d6be2084 | ||
|
|
73bac7b04c |
@ -46,6 +46,10 @@
|
|||||||
<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_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_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_name_exists">A playlist with this name already exists</string>
|
||||||
<string name="library_playlist_not_found">Playlist not found</string>
|
<string name="library_playlist_not_found">Playlist not found</string>
|
||||||
|
|||||||
@ -141,7 +141,7 @@ val sharedModules = module {
|
|||||||
singleOf(::LocalMediaCacheRepository)
|
singleOf(::LocalMediaCacheRepository)
|
||||||
singleOf(::LocalMediaFoldersConfig)
|
singleOf(::LocalMediaFoldersConfig)
|
||||||
singleOf(::LocalMediaLibraryCoordinator)
|
singleOf(::LocalMediaLibraryCoordinator)
|
||||||
singleOf(::LocalPlaylistRepository) // Local playlist repository
|
single { LocalPlaylistRepository(get(), get(), get()) } // Local playlist repository with sync
|
||||||
viewModelOf(::LibraryPlaylistsViewModel)
|
viewModelOf(::LibraryPlaylistsViewModel)
|
||||||
viewModelOf(::LocalPlaylistViewModel) // Local playlist view model
|
viewModelOf(::LocalPlaylistViewModel) // Local playlist view model
|
||||||
viewModelOf(::LibraryAlbumsViewModel)
|
viewModelOf(::LibraryAlbumsViewModel)
|
||||||
|
|||||||
@ -26,6 +26,38 @@ import org.jetbrains.compose.resources.stringResource
|
|||||||
import org.koin.compose.viewmodel.koinViewModel
|
import org.koin.compose.viewmodel.koinViewModel
|
||||||
import spotube.composeapp.generated.resources.*
|
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
|
@Composable
|
||||||
fun LibraryPlaylistsScreen(
|
fun LibraryPlaylistsScreen(
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
@ -54,6 +86,7 @@ fun LibraryPlaylistsScreen(
|
|||||||
val allPlaylists = spotifyPlaylists + localPlaylists
|
val allPlaylists = spotifyPlaylists + localPlaylists
|
||||||
val hasPlaylists = allPlaylists.isNotEmpty()
|
val hasPlaylists = allPlaylists.isNotEmpty()
|
||||||
val isLoading = current is LibraryPlaylistsState.Loading || localState is LocalPlaylistScreenState.Loading
|
val isLoading = current is LibraryPlaylistsState.Loading || localState is LocalPlaylistScreenState.Loading
|
||||||
|
val syncState = localData?.syncState ?: LocalPlaylistRepository.SyncState.Idle
|
||||||
|
|
||||||
if (!hasPlaylists && !isLoading) {
|
if (!hasPlaylists && !isLoading) {
|
||||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
@ -125,12 +158,88 @@ fun LibraryPlaylistsScreen(
|
|||||||
stringResource(Res.string.library_local_playlists_section),
|
stringResource(Res.string.library_local_playlists_section),
|
||||||
style = com.composeunstyled.Typography.h6
|
style = com.composeunstyled.Typography.h6
|
||||||
)
|
)
|
||||||
MillenniumIconButton(
|
// Sync button and status
|
||||||
icon = Iconsax.IconsaxAddSquare,
|
Box(
|
||||||
contentDescription = stringResource(Res.string.library_action_create_playlist),
|
modifier = Modifier
|
||||||
onClick = { localPlaylistViewModel.showCreatePlaylistDialog() },
|
.fillMaxWidth()
|
||||||
style = MillenniumIconButtonStyle.Quiet
|
.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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@ -17,6 +17,7 @@
|
|||||||
|
|
||||||
package org.spotube.spotube.modules.library.playlist
|
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.library.local_tracks.media.LocalMediaTrack
|
||||||
import org.spotube.spotube.modules.settings.SettingsRepository
|
import org.spotube.spotube.modules.settings.SettingsRepository
|
||||||
import kotlinx.coroutines.CoroutineScope
|
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.
|
* Local playlists are stored as M3U8 files in the configured playlists directory.
|
||||||
* They contain local tracks (from LocalMediaTrack) referenced by their file path.
|
* 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)
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
class LocalPlaylistRepository(
|
class LocalPlaylistRepository(
|
||||||
private val settingsRepository: SettingsRepository
|
private val settingsRepository: SettingsRepository,
|
||||||
|
private val jamRoomClient: JamRoomClient,
|
||||||
|
private val jamRoomService: JamRoomService
|
||||||
) : KoinComponent {
|
) : KoinComponent {
|
||||||
private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
|
private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
|
||||||
|
|
||||||
@ -56,10 +89,22 @@ class LocalPlaylistRepository(
|
|||||||
private val _playlists = MutableStateFlow<List<LocalPlaylist>>(emptyList())
|
private val _playlists = MutableStateFlow<List<LocalPlaylist>>(emptyList())
|
||||||
val playlists: StateFlow<List<LocalPlaylist>> = _playlists.asStateFlow()
|
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 isInitialized = false
|
||||||
|
private var currentSyncJob: kotlinx.coroutines.Job? = null
|
||||||
|
|
||||||
init {
|
init {
|
||||||
loadPlaylists()
|
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
|
private val playlistsDir: String
|
||||||
@ -107,6 +152,7 @@ class LocalPlaylistRepository(
|
|||||||
|
|
||||||
_playlists.update { it + playlist }
|
_playlists.update { it + playlist }
|
||||||
savePlaylist(playlist)
|
savePlaylist(playlist)
|
||||||
|
broadcastPlaylistChange(PlaylistChange.Created(playlist))
|
||||||
playlist
|
playlist
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -126,6 +172,7 @@ class LocalPlaylistRepository(
|
|||||||
list.set(index, updated)
|
list.set(index, updated)
|
||||||
}
|
}
|
||||||
savePlaylist(updated)
|
savePlaylist(updated)
|
||||||
|
broadcastPlaylistChange(PlaylistChange.Updated(updated))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -140,6 +187,7 @@ class LocalPlaylistRepository(
|
|||||||
file.delete()
|
file.delete()
|
||||||
|
|
||||||
_playlists.update { it.filter { it.id != playlistId } }
|
_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 }
|
list.map { if (it.id == playlistId) updated else it }
|
||||||
}
|
}
|
||||||
savePlaylist(updated)
|
savePlaylist(updated)
|
||||||
|
broadcastPlaylistChange(PlaylistChange.TrackAdded(playlistId, track.path))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -182,6 +231,7 @@ class LocalPlaylistRepository(
|
|||||||
list.map { if (it.id == playlistId) updated else it }
|
list.map { if (it.id == playlistId) updated else it }
|
||||||
}
|
}
|
||||||
savePlaylist(updated)
|
savePlaylist(updated)
|
||||||
|
broadcastPlaylistChange(PlaylistChange.TracksAdded(playlistId, newTrackPaths))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -201,6 +251,7 @@ class LocalPlaylistRepository(
|
|||||||
list.map { if (it.id == playlistId) updated else it }
|
list.map { if (it.id == playlistId) updated else it }
|
||||||
}
|
}
|
||||||
savePlaylist(updated)
|
savePlaylist(updated)
|
||||||
|
broadcastPlaylistChange(PlaylistChange.TrackRemoved(playlistId, trackPath))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -227,6 +278,7 @@ class LocalPlaylistRepository(
|
|||||||
list.map { if (it.id == playlistId) updated else it }
|
list.map { if (it.id == playlistId) updated else it }
|
||||||
}
|
}
|
||||||
savePlaylist(updated)
|
savePlaylist(updated)
|
||||||
|
broadcastPlaylistChange(PlaylistChange.Reordered(playlistId, newTrackPaths))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -298,6 +350,136 @@ class LocalPlaylistRepository(
|
|||||||
filePath = file.absolutePath
|
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
|
@Serializable
|
||||||
|
|||||||
@ -34,6 +34,7 @@ import kotlinx.coroutines.flow.combine
|
|||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import org.jetbrains.compose.resources.getString
|
import org.jetbrains.compose.resources.getString
|
||||||
import spotube.composeapp.generated.resources.Res
|
import spotube.composeapp.generated.resources.Res
|
||||||
import io.github.vinceglb.filekit.PlatformFile
|
import io.github.vinceglb.filekit.PlatformFile
|
||||||
@ -50,7 +51,8 @@ sealed interface LocalPlaylistScreenState {
|
|||||||
val showAddTracksDialog: Boolean = false,
|
val showAddTracksDialog: Boolean = false,
|
||||||
val editingPlaylist: LocalPlaylist? = null,
|
val editingPlaylist: LocalPlaylist? = null,
|
||||||
val isLoading: Boolean = false,
|
val isLoading: Boolean = false,
|
||||||
val error: String? = null
|
val error: String? = null,
|
||||||
|
val syncState: LocalPlaylistRepository.SyncState = LocalPlaylistRepository.SyncState.Idle
|
||||||
) : LocalPlaylistScreenState
|
) : LocalPlaylistScreenState
|
||||||
|
|
||||||
data object Loading : LocalPlaylistScreenState
|
data object Loading : LocalPlaylistScreenState
|
||||||
@ -97,11 +99,12 @@ class LocalPlaylistViewModel(
|
|||||||
init {
|
init {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
repository.playlists
|
repository.playlists
|
||||||
.combine(localTracksViewModel.state) { playlists, localTracksState ->
|
.combine(localTracksViewModel.state, repository.syncState) { playlists, localTracksState, syncState ->
|
||||||
LocalPlaylistScreenState.Data(
|
LocalPlaylistScreenState.Data(
|
||||||
playlists = playlists,
|
playlists = playlists,
|
||||||
query = "",
|
query = "",
|
||||||
selectedPlaylist = null
|
selectedPlaylist = null,
|
||||||
|
syncState = syncState
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
.stateIn(
|
.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