mirror of
https://github.com/KRTirtho/spotube.git
synced 2026-11-06 11:14:00 +00:00
Compare commits
1 Commits
00d6be2084
...
060fb631dd
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
060fb631dd |
@ -46,10 +46,6 @@
|
|||||||
<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)
|
||||||
single { LocalPlaylistRepository(get(), get(), get()) } // Local playlist repository with sync
|
singleOf(::LocalPlaylistRepository) // Local playlist repository
|
||||||
viewModelOf(::LibraryPlaylistsViewModel)
|
viewModelOf(::LibraryPlaylistsViewModel)
|
||||||
viewModelOf(::LocalPlaylistViewModel) // Local playlist view model
|
viewModelOf(::LocalPlaylistViewModel) // Local playlist view model
|
||||||
viewModelOf(::LibraryAlbumsViewModel)
|
viewModelOf(::LibraryAlbumsViewModel)
|
||||||
|
|||||||
@ -26,38 +26,6 @@ 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,
|
||||||
@ -86,7 +54,6 @@ 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) {
|
||||||
@ -158,88 +125,12 @@ 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
|
||||||
)
|
)
|
||||||
// Sync button and status
|
MillenniumIconButton(
|
||||||
Box(
|
icon = Iconsax.IconsaxAddSquare,
|
||||||
modifier = Modifier
|
contentDescription = stringResource(Res.string.library_action_create_playlist),
|
||||||
.fillMaxWidth()
|
onClick = { localPlaylistViewModel.showCreatePlaylistDialog() },
|
||||||
.padding(top = 4.dp),
|
style = MillenniumIconButtonStyle.Quiet
|
||||||
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,7 +17,6 @@
|
|||||||
|
|
||||||
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
|
||||||
@ -43,41 +42,9 @@ 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())
|
||||||
|
|
||||||
@ -89,22 +56,10 @@ 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
|
||||||
@ -152,7 +107,6 @@ class LocalPlaylistRepository(
|
|||||||
|
|
||||||
_playlists.update { it + playlist }
|
_playlists.update { it + playlist }
|
||||||
savePlaylist(playlist)
|
savePlaylist(playlist)
|
||||||
broadcastPlaylistChange(PlaylistChange.Created(playlist))
|
|
||||||
playlist
|
playlist
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -172,7 +126,6 @@ class LocalPlaylistRepository(
|
|||||||
list.set(index, updated)
|
list.set(index, updated)
|
||||||
}
|
}
|
||||||
savePlaylist(updated)
|
savePlaylist(updated)
|
||||||
broadcastPlaylistChange(PlaylistChange.Updated(updated))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -187,7 +140,6 @@ class LocalPlaylistRepository(
|
|||||||
file.delete()
|
file.delete()
|
||||||
|
|
||||||
_playlists.update { it.filter { it.id != playlistId } }
|
_playlists.update { it.filter { it.id != playlistId } }
|
||||||
broadcastPlaylistChange(PlaylistChange.Deleted(playlistId))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -210,7 +162,6 @@ 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))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -231,7 +182,6 @@ 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))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -251,7 +201,6 @@ 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))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -278,7 +227,6 @@ 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))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -350,136 +298,6 @@ 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,7 +34,6 @@ 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
|
||||||
@ -51,8 +50,7 @@ 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
|
||||||
@ -99,12 +97,11 @@ class LocalPlaylistViewModel(
|
|||||||
init {
|
init {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
repository.playlists
|
repository.playlists
|
||||||
.combine(localTracksViewModel.state, repository.syncState) { playlists, localTracksState, syncState ->
|
.combine(localTracksViewModel.state) { playlists, localTracksState ->
|
||||||
LocalPlaylistScreenState.Data(
|
LocalPlaylistScreenState.Data(
|
||||||
playlists = playlists,
|
playlists = playlists,
|
||||||
query = "",
|
query = "",
|
||||||
selectedPlaylist = null,
|
selectedPlaylist = null
|
||||||
syncState = syncState
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
.stateIn(
|
.stateIn(
|
||||||
@ -298,29 +295,4 @@ 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