From 5fffe34d29411df894345dbd91071f385494e35f Mon Sep 17 00:00:00 2001 From: MakD Date: Tue, 2 Jun 2026 00:50:08 +0530 Subject: [PATCH 1/2] feat: implement SyncPlay support for synchronized watch parties This commit introduces SyncPlay functionality, enabling users to create, join, and manage "Watch Parties" for synchronized playback. It includes a time synchronization engine to handle clock skew, real-time coordination via Jellyfin WebSockets, and a dedicated UI for group management. ### Key Changes: * **Data Layer**: * Added `SyncPlayRepository` and `JellyfinSyncPlayRepository` to handle API interactions for group lifecycle and playback reporting. * Implemented `SyncPlayRawWebSocket` for low-level parsing of real-time group events and commands. * Created `SyncPlayTimeSyncEngine` to calculate clock offsets between the client and server for precise event scheduling. * **UI Components**: * **`SyncPlayGroupSheet`**: A new dialog for creating, joining, and viewing active watch party groups and members. * **`SyncPlayWaitingOverlay`**: Added a UI state to indicate when playback is paused waiting for other participants. * **`PlayerControls`**: Added a "Watch Party" toggle button and integrated it with the player UI. * **Logic & Integration**: * Introduced `SyncPlayViewModel` to manage watch party state and coordinate between the repository and the player. * Updated `PlayerViewModel` with a `SyncPlayInterceptor` pattern to allow SyncPlay to intercept and broadcast local playback events (play/pause/seek). * Integrated lifecycle observers in `PlayerScreen` to report buffering and readiness states when the app moves between foreground and background. * **Infrastructure**: * Updated `JellyfinWebSocketManager` to subscribe to SyncPlay-specific command and update messages. * Registered `SyncPlayRepository` in `RepositoryModule` for Hilt dependency injection. --- .../data/models/media/AfinitySource.kt | 10 +- .../data/models/syncplay/SyncPlayState.kt | 19 + .../syncplay/JellyfinSyncPlayRepository.kt | 254 +++++++++++ .../repository/syncplay/SyncPlayRepository.kt | 47 ++ .../data/syncplay/SyncPlayGroupEvent.kt | 24 + .../data/syncplay/SyncPlayGroupUpdate.kt | 9 + .../data/syncplay/SyncPlayRawWebSocket.kt | 209 +++++++++ .../data/syncplay/SyncPlayTimeSyncEngine.kt | 114 +++++ .../websocket/JellyfinWebSocketManager.kt | 47 ++ .../com/makd/afinity/di/RepositoryModule.kt | 8 + .../makd/afinity/ui/player/PlayerScreen.kt | 84 +++- .../makd/afinity/ui/player/PlayerViewModel.kt | 16 + .../afinity/ui/player/SyncPlayViewModel.kt | 410 ++++++++++++++++++ .../ui/player/components/PlayerControls.kt | 21 +- .../player/components/SyncPlayGroupSheet.kt | 339 +++++++++++++++ .../components/SyncPlayWaitingOverlay.kt | 46 ++ app/src/main/res/drawable/ic_users_group.xml | 42 ++ 17 files changed, 1691 insertions(+), 8 deletions(-) create mode 100644 app/src/main/java/com/makd/afinity/data/models/syncplay/SyncPlayState.kt create mode 100644 app/src/main/java/com/makd/afinity/data/repository/syncplay/JellyfinSyncPlayRepository.kt create mode 100644 app/src/main/java/com/makd/afinity/data/repository/syncplay/SyncPlayRepository.kt create mode 100644 app/src/main/java/com/makd/afinity/data/syncplay/SyncPlayGroupEvent.kt create mode 100644 app/src/main/java/com/makd/afinity/data/syncplay/SyncPlayGroupUpdate.kt create mode 100644 app/src/main/java/com/makd/afinity/data/syncplay/SyncPlayRawWebSocket.kt create mode 100644 app/src/main/java/com/makd/afinity/data/syncplay/SyncPlayTimeSyncEngine.kt create mode 100644 app/src/main/java/com/makd/afinity/ui/player/SyncPlayViewModel.kt create mode 100644 app/src/main/java/com/makd/afinity/ui/player/components/SyncPlayGroupSheet.kt create mode 100644 app/src/main/java/com/makd/afinity/ui/player/components/SyncPlayWaitingOverlay.kt create mode 100644 app/src/main/res/drawable/ic_users_group.xml diff --git a/app/src/main/java/com/makd/afinity/data/models/media/AfinitySource.kt b/app/src/main/java/com/makd/afinity/data/models/media/AfinitySource.kt index 283dac63..42d16b28 100644 --- a/app/src/main/java/com/makd/afinity/data/models/media/AfinitySource.kt +++ b/app/src/main/java/com/makd/afinity/data/models/media/AfinitySource.kt @@ -2,11 +2,11 @@ package com.makd.afinity.data.models.media import com.makd.afinity.data.database.dao.ServerDatabaseDao import com.makd.afinity.data.database.entities.AfinitySourceDto -import java.io.File -import java.util.UUID -import org.jellyfin.sdk.model.api.MediaStreamType import org.jellyfin.sdk.model.api.MediaProtocol import org.jellyfin.sdk.model.api.MediaSourceInfo +import org.jellyfin.sdk.model.api.MediaStreamType +import java.io.File +import java.util.UUID data class AfinitySource( val id: String, @@ -16,7 +16,6 @@ data class AfinitySource( val size: Long, val mediaStreams: List, val downloadId: Long? = null, - // Version display metadata val bitrate: Long? = null, val container: String? = null, val videoCodec: String? = null, @@ -46,8 +45,7 @@ suspend fun MediaSourceInfo.toAfinitySource( type = AfinitySourceType.REMOTE, path = path, size = size ?: 0, - mediaStreams = - mediaStreams?.map { it.toAfinityMediaStream(baseUrl) } ?: emptyList(), + mediaStreams = mediaStreams?.map { it.toAfinityMediaStream(baseUrl) } ?: emptyList(), bitrate = bitrate?.toLong(), container = container, videoCodec = videoStream?.codec, diff --git a/app/src/main/java/com/makd/afinity/data/models/syncplay/SyncPlayState.kt b/app/src/main/java/com/makd/afinity/data/models/syncplay/SyncPlayState.kt new file mode 100644 index 00000000..b05a20b7 --- /dev/null +++ b/app/src/main/java/com/makd/afinity/data/models/syncplay/SyncPlayState.kt @@ -0,0 +1,19 @@ +package com.makd.afinity.data.models.syncplay + +import org.jellyfin.sdk.model.UUID +import org.jellyfin.sdk.model.api.GroupRepeatMode +import org.jellyfin.sdk.model.api.GroupShuffleMode +import org.jellyfin.sdk.model.api.GroupStateType +import org.jellyfin.sdk.model.api.SyncPlayQueueItem + +data class SyncPlayState( + val isInGroup: Boolean = false, + val groupId: UUID? = null, + val groupName: String = "", + val members: List = emptyList(), + val groupState: GroupStateType = GroupStateType.IDLE, + val queue: List = emptyList(), + val playingItemIndex: Int = 0, + val shuffleMode: GroupShuffleMode = GroupShuffleMode.SORTED, + val repeatMode: GroupRepeatMode = GroupRepeatMode.REPEAT_NONE, +) diff --git a/app/src/main/java/com/makd/afinity/data/repository/syncplay/JellyfinSyncPlayRepository.kt b/app/src/main/java/com/makd/afinity/data/repository/syncplay/JellyfinSyncPlayRepository.kt new file mode 100644 index 00000000..833207d8 --- /dev/null +++ b/app/src/main/java/com/makd/afinity/data/repository/syncplay/JellyfinSyncPlayRepository.kt @@ -0,0 +1,254 @@ +package com.makd.afinity.data.repository.syncplay + +import com.makd.afinity.data.manager.SessionManager +import com.makd.afinity.data.models.syncplay.SyncPlayState +import com.makd.afinity.data.syncplay.SyncPlayGroupEvent +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.withContext +import org.jellyfin.sdk.api.client.exception.ApiClientException +import org.jellyfin.sdk.api.operations.SyncPlayApi +import org.jellyfin.sdk.model.DateTime +import org.jellyfin.sdk.model.UUID +import org.jellyfin.sdk.model.api.BufferRequestDto +import org.jellyfin.sdk.model.api.GroupInfoDto +import org.jellyfin.sdk.model.api.JoinGroupRequestDto +import org.jellyfin.sdk.model.api.NewGroupRequestDto +import org.jellyfin.sdk.model.api.PingRequestDto +import org.jellyfin.sdk.model.api.PlayRequestDto +import org.jellyfin.sdk.model.api.ReadyRequestDto +import org.jellyfin.sdk.model.api.SeekRequestDto +import timber.log.Timber +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class JellyfinSyncPlayRepository @Inject constructor(private val sessionManager: SessionManager) : + SyncPlayRepository { + + private val _syncPlayState = MutableStateFlow(SyncPlayState()) + override val syncPlayState: StateFlow = _syncPlayState.asStateFlow() + + private fun syncPlayApi(): SyncPlayApi? { + val client = sessionManager.getCurrentApiClient() ?: return null + return SyncPlayApi(client) + } + + override suspend fun getGroups(): List = + withContext(Dispatchers.IO) { + try { + syncPlayApi()?.syncPlayGetGroups()?.content ?: emptyList() + } catch (e: ApiClientException) { + Timber.e(e, "Failed to get SyncPlay groups") + emptyList() + } catch (e: Exception) { + Timber.e(e, "Unexpected error getting SyncPlay groups") + emptyList() + } + } + + override suspend fun createGroup(name: String): Unit = + withContext(Dispatchers.IO) { + try { + syncPlayApi()?.syncPlayCreateGroup(NewGroupRequestDto(groupName = name)) + Timber.d("SyncPlay: created group '$name'") + } catch (e: ApiClientException) { + Timber.e(e, "Failed to create SyncPlay group '$name'") + } catch (e: Exception) { + Timber.e(e, "Unexpected error creating SyncPlay group '$name'") + } + } + + override suspend fun joinGroup(groupId: UUID): Unit = + withContext(Dispatchers.IO) { + try { + syncPlayApi()?.syncPlayJoinGroup(JoinGroupRequestDto(groupId = groupId)) + Timber.d("SyncPlay: join request sent for group $groupId") + } catch (e: ApiClientException) { + Timber.e(e, "Failed to join SyncPlay group $groupId") + } catch (e: Exception) { + Timber.e(e, "Unexpected error joining SyncPlay group $groupId") + } + } + + override suspend fun leaveGroup(): Unit = + withContext(Dispatchers.IO) { + try { + syncPlayApi()?.syncPlayLeaveGroup() + _syncPlayState.value = SyncPlayState() + Timber.d("SyncPlay: left group") + } catch (e: ApiClientException) { + Timber.e(e, "Failed to leave SyncPlay group") + } catch (e: Exception) { + Timber.e(e, "Unexpected error leaving SyncPlay group") + } + } + + override suspend fun pause(): Unit = + withContext(Dispatchers.IO) { + try { + syncPlayApi()?.syncPlayPause() + } catch (e: ApiClientException) { + Timber.e(e, "Failed to send SyncPlay pause") + } catch (e: Exception) { + Timber.e(e, "Unexpected error sending SyncPlay pause") + } + } + + override suspend fun unpause(): Unit = + withContext(Dispatchers.IO) { + try { + syncPlayApi()?.syncPlayUnpause() + } catch (e: ApiClientException) { + Timber.e(e, "Failed to send SyncPlay unpause") + } catch (e: Exception) { + Timber.e(e, "Unexpected error sending SyncPlay unpause") + } + } + + override suspend fun seek(positionTicks: Long): Unit = + withContext(Dispatchers.IO) { + try { + syncPlayApi()?.syncPlaySeek(SeekRequestDto(positionTicks = positionTicks)) + } catch (e: ApiClientException) { + Timber.e(e, "Failed to send SyncPlay seek to $positionTicks ticks") + } catch (e: Exception) { + Timber.e(e, "Unexpected error sending SyncPlay seek") + } + } + + override suspend fun stop(): Unit = + withContext(Dispatchers.IO) { + try { + syncPlayApi()?.syncPlayStop() + } catch (e: ApiClientException) { + Timber.e(e, "Failed to send SyncPlay stop") + } catch (e: Exception) { + Timber.e(e, "Unexpected error sending SyncPlay stop") + } + } + + override suspend fun reportBuffering( + positionTicks: Long, + isPlaying: Boolean, + playlistItemId: UUID, + ): Unit = + withContext(Dispatchers.IO) { + try { + syncPlayApi() + ?.syncPlayBuffering( + BufferRequestDto( + `when` = DateTime.now(), + positionTicks = positionTicks, + isPlaying = isPlaying, + playlistItemId = playlistItemId, + ) + ) + } catch (e: ApiClientException) { + Timber.e(e, "Failed to report SyncPlay buffering") + } catch (e: Exception) { + Timber.e(e, "Unexpected error reporting SyncPlay buffering") + } + } + + override suspend fun reportReady( + positionTicks: Long, + isPlaying: Boolean, + playlistItemId: UUID, + ): Unit = + withContext(Dispatchers.IO) { + try { + syncPlayApi() + ?.syncPlayReady( + ReadyRequestDto( + `when` = DateTime.now(), + positionTicks = positionTicks, + isPlaying = isPlaying, + playlistItemId = playlistItemId, + ) + ) + } catch (e: ApiClientException) { + Timber.e(e, "Failed to report SyncPlay ready") + } catch (e: Exception) { + Timber.e(e, "Unexpected error reporting SyncPlay ready") + } + } + + override suspend fun ping(clientTimeMs: Long): Unit = + withContext(Dispatchers.IO) { + try { + syncPlayApi()?.syncPlayPing(PingRequestDto(ping = clientTimeMs)) + } catch (e: ApiClientException) { + Timber.e(e, "Failed to send SyncPlay ping") + } catch (e: Exception) { + Timber.e(e, "Unexpected error sending SyncPlay ping") + } + } + + override suspend fun setNewQueue(itemIds: List, position: Int, startPositionTicks: Long) = + withContext(Dispatchers.IO) { + try { + syncPlayApi() + ?.syncPlaySetNewQueue( + PlayRequestDto( + playingQueue = itemIds, + playingItemPosition = position, + startPositionTicks = startPositionTicks, + ) + ) + Timber.d( + "SyncPlay: set new queue — ${itemIds.size} item(s) at ${startPositionTicks / 10_000}ms" + ) + } catch (e: ApiClientException) { + Timber.e(e, "Failed to set SyncPlay queue") + } catch (e: Exception) { + Timber.e(e, "Unexpected error setting SyncPlay queue") + } + } + + override fun setGroupJoined(groupId: UUID) { + _syncPlayState.value = SyncPlayState(isInGroup = true, groupId = groupId) + } + + override fun updateFromGroupEvent(event: SyncPlayGroupEvent) { + val current = _syncPlayState.value + _syncPlayState.value = + when (event) { + is SyncPlayGroupEvent.GroupStateRefreshed -> + SyncPlayState( + isInGroup = true, + groupId = event.groupInfo.groupId, + groupName = event.groupInfo.groupName, + members = event.groupInfo.participants, + groupState = event.groupInfo.state, + ) + is SyncPlayGroupEvent.GroupLeft -> SyncPlayState() + is SyncPlayGroupEvent.StateChanged -> current.copy(groupState = event.newState) + is SyncPlayGroupEvent.UserJoined -> { + if (event.userName !in current.members) + current.copy(members = current.members + event.userName) + else current + } + is SyncPlayGroupEvent.UserLeft -> + current.copy(members = current.members.filter { it != event.userName }) + is SyncPlayGroupEvent.QueueChanged -> { + val u = event.update + if (u != null) { + current.copy( + queue = u.playlist, + playingItemIndex = u.playingItemIndex, + shuffleMode = u.shuffleMode, + repeatMode = u.repeatMode, + ) + } else current + } + is SyncPlayGroupEvent.Error -> { + Timber.w("SyncPlay group error: ${event.type} — ${event.message}") + current + } + else -> current + } + } +} diff --git a/app/src/main/java/com/makd/afinity/data/repository/syncplay/SyncPlayRepository.kt b/app/src/main/java/com/makd/afinity/data/repository/syncplay/SyncPlayRepository.kt new file mode 100644 index 00000000..193fc2b2 --- /dev/null +++ b/app/src/main/java/com/makd/afinity/data/repository/syncplay/SyncPlayRepository.kt @@ -0,0 +1,47 @@ +package com.makd.afinity.data.repository.syncplay + +import com.makd.afinity.data.models.syncplay.SyncPlayState +import kotlinx.coroutines.flow.StateFlow +import org.jellyfin.sdk.model.UUID +import org.jellyfin.sdk.model.api.GroupInfoDto + +interface SyncPlayRepository { + + val syncPlayState: StateFlow + + suspend fun getGroups(): List + + suspend fun createGroup(name: String) + + suspend fun joinGroup(groupId: UUID) + + suspend fun leaveGroup() + + suspend fun pause() + + suspend fun unpause() + + suspend fun seek(positionTicks: Long) + + suspend fun stop() + + suspend fun reportBuffering( + positionTicks: Long, + isPlaying: Boolean, + playlistItemId: UUID, + ) + + suspend fun reportReady( + positionTicks: Long, + isPlaying: Boolean, + playlistItemId: UUID, + ) + + suspend fun ping(clientTimeMs: Long) + + fun updateFromGroupEvent(event: com.makd.afinity.data.syncplay.SyncPlayGroupEvent) + + fun setGroupJoined(groupId: UUID) + + suspend fun setNewQueue(itemIds: List, position: Int, startPositionTicks: Long) +} \ No newline at end of file diff --git a/app/src/main/java/com/makd/afinity/data/syncplay/SyncPlayGroupEvent.kt b/app/src/main/java/com/makd/afinity/data/syncplay/SyncPlayGroupEvent.kt new file mode 100644 index 00000000..d40db477 --- /dev/null +++ b/app/src/main/java/com/makd/afinity/data/syncplay/SyncPlayGroupEvent.kt @@ -0,0 +1,24 @@ +package com.makd.afinity.data.syncplay + +import org.jellyfin.sdk.model.UUID +import org.jellyfin.sdk.model.api.GroupInfoDto +import org.jellyfin.sdk.model.api.GroupStateType +import org.jellyfin.sdk.model.api.GroupUpdateType +import org.jellyfin.sdk.model.api.PlayQueueUpdate + +sealed class SyncPlayGroupEvent { + data class GroupStateRefreshed(val groupInfo: GroupInfoDto) : SyncPlayGroupEvent() + + data class GroupLeft(val groupId: UUID) : SyncPlayGroupEvent() + + data class StateChanged(val groupId: UUID, val newState: GroupStateType) : SyncPlayGroupEvent() + + data class UserJoined(val groupId: UUID, val userName: String) : SyncPlayGroupEvent() + + data class UserLeft(val groupId: UUID, val userName: String) : SyncPlayGroupEvent() + + data class QueueChanged(val groupId: UUID, val update: PlayQueueUpdate? = null) : + SyncPlayGroupEvent() + + data class Error(val type: GroupUpdateType, val message: String) : SyncPlayGroupEvent() +} diff --git a/app/src/main/java/com/makd/afinity/data/syncplay/SyncPlayGroupUpdate.kt b/app/src/main/java/com/makd/afinity/data/syncplay/SyncPlayGroupUpdate.kt new file mode 100644 index 00000000..c206abcf --- /dev/null +++ b/app/src/main/java/com/makd/afinity/data/syncplay/SyncPlayGroupUpdate.kt @@ -0,0 +1,9 @@ +package com.makd.afinity.data.syncplay + +import org.jellyfin.sdk.model.UUID +import org.jellyfin.sdk.model.api.GroupUpdateType + +data class SyncPlayGroupUpdate( + val type: GroupUpdateType, + val groupId: UUID, +) diff --git a/app/src/main/java/com/makd/afinity/data/syncplay/SyncPlayRawWebSocket.kt b/app/src/main/java/com/makd/afinity/data/syncplay/SyncPlayRawWebSocket.kt new file mode 100644 index 00000000..ebec55f2 --- /dev/null +++ b/app/src/main/java/com/makd/afinity/data/syncplay/SyncPlayRawWebSocket.kt @@ -0,0 +1,209 @@ +package com.makd.afinity.data.syncplay + +import com.makd.afinity.data.manager.SessionManager +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.launch +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.Response +import okhttp3.WebSocket +import okhttp3.WebSocketListener +import org.jellyfin.sdk.api.client.util.ApiSerializer +import org.jellyfin.sdk.model.api.GroupInfoDto +import org.jellyfin.sdk.model.api.GroupStateUpdate +import org.jellyfin.sdk.model.api.GroupUpdateType +import org.jellyfin.sdk.model.api.PlayQueueUpdate +import org.jellyfin.sdk.model.api.SendCommand +import timber.log.Timber +import java.util.concurrent.TimeUnit +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class SyncPlayRawWebSocket +@Inject +constructor( + private val sessionManager: SessionManager, + private val okHttpClient: OkHttpClient, +) { + private val _groupEvents = MutableSharedFlow(extraBufferCapacity = 16) + val groupEvents: SharedFlow = _groupEvents.asSharedFlow() + + private val _playQueueUpdates = MutableSharedFlow(extraBufferCapacity = 4) + val playQueueUpdates: SharedFlow = _playQueueUpdates.asSharedFlow() + + private val _commands = MutableSharedFlow(extraBufferCapacity = 16) + val commands: SharedFlow = _commands.asSharedFlow() + + private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + + private val wsClient = okHttpClient.newBuilder().pingInterval(30, TimeUnit.SECONDS).build() + + @Volatile private var activeSocket: WebSocket? = null + @Volatile private var started = false + + fun start() { + if (started) return + val apiClient = sessionManager.getCurrentApiClient() ?: return + val baseUrl = apiClient.baseUrl ?: return + val token = apiClient.accessToken ?: return + started = true + connect(baseUrl, token) + } + + fun stop() { + started = false + activeSocket?.close(1000, "SyncPlay session ended") + activeSocket = null + } + + private fun connect(baseUrl: String, token: String) { + val wsUrl = + baseUrl.trimEnd('/').replace("https://", "wss://").replace("http://", "ws://") + + "/socket?api_key=$token" + Timber.d("SyncPlay: opening dedicated WebSocket") + val request = Request.Builder().url(wsUrl).build() + activeSocket = wsClient.newWebSocket(request, Listener()) + } + + private inner class Listener : WebSocketListener() { + override fun onOpen(webSocket: WebSocket, response: Response) { + Timber.d("SyncPlay: dedicated WebSocket connected") + } + + override fun onMessage(webSocket: WebSocket, text: String) { + scope.launch { parseMessage(text) } + } + + override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) { + Timber.e(t, "SyncPlay: WebSocket failure") + activeSocket = null + if (!started) return + scope.launch { + delay(3_000L) + if (!started) return@launch + val apiClient = sessionManager.getCurrentApiClient() ?: return@launch + val baseUrl = apiClient.baseUrl ?: return@launch + val token = apiClient.accessToken ?: return@launch + connect(baseUrl, token) + } + } + + override fun onClosed(webSocket: WebSocket, code: Int, reason: String) { + Timber.d("SyncPlay: dedicated WebSocket closed ($code: $reason)") + activeSocket = null + } + } + + private suspend fun parseMessage(text: String) { + if (!text.contains("SyncPlay")) return + try { + val root = ApiSerializer.json.parseToJsonElement(text).jsonObject + when (root["MessageType"]?.jsonPrimitive?.contentOrNull) { + "SyncPlayCommand" -> parseSyncPlayCommand(root) + "SyncPlayGroupUpdate" -> parseSyncPlayGroupUpdate(root) + else -> {} + } + } catch (e: Exception) { + Timber.e(e, "SyncPlay: failed to parse raw frame") + } + } + + private suspend fun parseSyncPlayCommand(root: kotlinx.serialization.json.JsonObject) { + val data = root["Data"]?.jsonObject ?: return + try { + val command = ApiSerializer.json.decodeFromJsonElement(SendCommand.serializer(), data) + Timber.d("SyncPlay raw command: ${command.command}, ticks=${command.positionTicks}") + _commands.emit(command) + } catch (e: Exception) { + Timber.e(e, "SyncPlay: failed to deserialize SendCommand") + } + } + + private suspend fun parseSyncPlayGroupUpdate(root: kotlinx.serialization.json.JsonObject) { + try { + val outerData = root["Data"]?.jsonObject ?: return + val typeStr = outerData["Type"]?.jsonPrimitive?.contentOrNull ?: return + val groupUpdateType = GroupUpdateType.fromNameOrNull(typeStr) ?: return + val innerData = outerData["Data"] + + when (groupUpdateType) { + GroupUpdateType.GROUP_JOINED, + GroupUpdateType.GROUP_LEFT -> { + if (innerData == null) return + val groupInfo = + ApiSerializer.json.decodeFromJsonElement( + GroupInfoDto.serializer(), + innerData, + ) + val event = + if (groupUpdateType == GroupUpdateType.GROUP_JOINED) + SyncPlayGroupEvent.GroupStateRefreshed(groupInfo) + else SyncPlayGroupEvent.GroupLeft(groupInfo.groupId) + _groupEvents.emit(event) + Timber.d( + "SyncPlay raw: $groupUpdateType — group=${groupInfo.groupName}, state=${groupInfo.state}" + ) + } + GroupUpdateType.STATE_UPDATE -> { + if (innerData == null) return + val stateUpdate = + ApiSerializer.json.decodeFromJsonElement( + GroupStateUpdate.serializer(), + innerData, + ) + val groupId = + outerData["GroupId"]?.let { + ApiSerializer.json.decodeFromJsonElement( + org.jellyfin.sdk.model.serializer.UUIDSerializer(), + it, + ) + } ?: return + _groupEvents.emit(SyncPlayGroupEvent.StateChanged(groupId, stateUpdate.state)) + Timber.d("SyncPlay raw: STATE_UPDATE → ${stateUpdate.state}") + } + GroupUpdateType.USER_JOINED, + GroupUpdateType.USER_LEFT -> { + val userName = innerData?.jsonPrimitive?.contentOrNull ?: return + val groupId = + outerData["GroupId"]?.let { + ApiSerializer.json.decodeFromJsonElement( + org.jellyfin.sdk.model.serializer.UUIDSerializer(), + it, + ) + } ?: return + val event = + if (groupUpdateType == GroupUpdateType.USER_JOINED) + SyncPlayGroupEvent.UserJoined(groupId, userName) + else SyncPlayGroupEvent.UserLeft(groupId, userName) + _groupEvents.emit(event) + Timber.d("SyncPlay raw: $groupUpdateType — user=$userName") + } + GroupUpdateType.PLAY_QUEUE -> { + if (innerData == null) return + val update = + ApiSerializer.json.decodeFromJsonElement( + PlayQueueUpdate.serializer(), + innerData, + ) + _playQueueUpdates.emit(update) + Timber.d( + "SyncPlay raw: PLAY_QUEUE — ${update.playlist.size} items, idx=${update.playingItemIndex}" + ) + } + else -> {} + } + } catch (e: Exception) { + Timber.e(e, "SyncPlay: failed to parse raw frame") + } + } +} diff --git a/app/src/main/java/com/makd/afinity/data/syncplay/SyncPlayTimeSyncEngine.kt b/app/src/main/java/com/makd/afinity/data/syncplay/SyncPlayTimeSyncEngine.kt new file mode 100644 index 00000000..a22059b2 --- /dev/null +++ b/app/src/main/java/com/makd/afinity/data/syncplay/SyncPlayTimeSyncEngine.kt @@ -0,0 +1,114 @@ +package com.makd.afinity.data.syncplay + +import com.makd.afinity.data.manager.SessionManager +import com.makd.afinity.data.repository.syncplay.SyncPlayRepository +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.jellyfin.sdk.api.operations.TimeSyncApi +import org.jellyfin.sdk.model.DateTime +import timber.log.Timber +import java.time.ZoneId +import java.util.concurrent.atomic.AtomicLong +import javax.inject.Inject +import javax.inject.Singleton + +private const val PING_ROUNDS = 4 +private const val RESYNC_INTERVAL_MS = 30_000L + +@Singleton +class SyncPlayTimeSyncEngine +@Inject +constructor( + private val sessionManager: SessionManager, + private val syncPlayRepository: SyncPlayRepository, +) { + private val _clockOffsetMs = AtomicLong(0L) + + val clockOffsetMs: Long + get() = _clockOffsetMs.get() + + private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + private var reSyncJob: Job? = null + + suspend fun syncOnJoin() { + performSync() + startPeriodicSync() + } + + fun stop() { + reSyncJob?.cancel() + reSyncJob = null + _clockOffsetMs.set(0L) + } + + fun toScheduledDelayMs(serverTime: DateTime): Long { + val fireAtMs = serverTime.toEpochMs() - _clockOffsetMs.get() + return fireAtMs - System.currentTimeMillis() + } + + private fun startPeriodicSync() { + reSyncJob?.cancel() + reSyncJob = scope.launch { + while (isActive) { + delay(RESYNC_INTERVAL_MS) + if (isActive) performSync() + } + } + } + + private suspend fun performSync() { + withContext(Dispatchers.IO) { + val api = timeSyncApi() ?: return@withContext + val offsets = mutableListOf() + + repeat(PING_ROUNDS) { round -> + try { + val t1 = System.currentTimeMillis() + val response = api.getUtcTime().content + val t2 = System.currentTimeMillis() + + val ts1 = response.requestReceptionTime.toEpochMs() + val ts2 = response.responseTransmissionTime.toEpochMs() + + val rtt = t2 - t1 + val serverProcessingMs = ts2 - ts1 + val oneWayLatency = (rtt - serverProcessingMs) / 2 + val offset = ts1 - t1 - oneWayLatency + + offsets += offset + + syncPlayRepository.ping(clientTimeMs = rtt) + + Timber.d( + "SyncPlay time sync round ${round + 1}/$PING_ROUNDS: rtt=${rtt}ms offset=${offset}ms" + ) + } catch (e: Exception) { + Timber.w(e, "SyncPlay time sync round ${round + 1} failed, skipping") + } + } + + if (offsets.isNotEmpty()) { + _clockOffsetMs.set(offsets.average().toLong()) + Timber.d( + "SyncPlay clock offset updated: ${_clockOffsetMs.get()}ms (${offsets.size}/${PING_ROUNDS} rounds)" + ) + } else { + Timber.w("SyncPlay time sync: all rounds failed, keeping previous offset") + } + } + } + + private fun timeSyncApi(): TimeSyncApi? { + val client = sessionManager.getCurrentApiClient() ?: return null + return TimeSyncApi(client) + } +} + +private fun DateTime.toEpochMs(): Long = + this.atZone(ZoneId.systemDefault()).toInstant().toEpochMilli() diff --git a/app/src/main/java/com/makd/afinity/data/websocket/JellyfinWebSocketManager.kt b/app/src/main/java/com/makd/afinity/data/websocket/JellyfinWebSocketManager.kt index 83b65da3..9598fe80 100644 --- a/app/src/main/java/com/makd/afinity/data/websocket/JellyfinWebSocketManager.kt +++ b/app/src/main/java/com/makd/afinity/data/websocket/JellyfinWebSocketManager.kt @@ -7,6 +7,7 @@ import com.makd.afinity.data.manager.MediaChangeManager import com.makd.afinity.data.manager.MediaRefreshBus import com.makd.afinity.data.manager.RefreshTrigger import com.makd.afinity.data.manager.SessionManager +import com.makd.afinity.data.syncplay.SyncPlayGroupUpdate import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job @@ -27,10 +28,13 @@ import org.jellyfin.sdk.model.api.LibraryChangedMessage import org.jellyfin.sdk.model.api.PlayMessage import org.jellyfin.sdk.model.api.PlaystateMessage import org.jellyfin.sdk.model.api.ScheduledTasksInfoMessage +import org.jellyfin.sdk.model.api.SendCommand import org.jellyfin.sdk.model.api.ServerRestartingMessage import org.jellyfin.sdk.model.api.ServerShuttingDownMessage import org.jellyfin.sdk.model.api.SessionInfoDto import org.jellyfin.sdk.model.api.SessionsMessage +import org.jellyfin.sdk.model.api.SyncPlayCommandMessage +import org.jellyfin.sdk.model.api.SyncPlayGroupUpdateCommandMessage import org.jellyfin.sdk.model.api.TaskInfo import org.jellyfin.sdk.model.api.TaskState import org.jellyfin.sdk.model.api.UserDataChangedMessage @@ -58,6 +62,13 @@ constructor( private val _liveTasks = MutableSharedFlow>(replay = 1) val liveTasks = _liveTasks.asSharedFlow() + private val _syncPlayCommands = MutableSharedFlow(extraBufferCapacity = 16) + val syncPlayCommands: SharedFlow = _syncPlayCommands.asSharedFlow() + + private val _syncPlayGroupUpdates = + MutableSharedFlow(extraBufferCapacity = 16) + val syncPlayGroupUpdates: SharedFlow = _syncPlayGroupUpdates.asSharedFlow() + init { ProcessLifecycleOwner.get().lifecycle.addObserver(this) @@ -99,6 +110,8 @@ constructor( launch { subscribeToPlayCommands(currentApiClient) } launch { subscribeToServerMessages(currentApiClient) } launch { subscribeToTaskChanges(currentApiClient) } + launch { subscribeToSyncPlayCommands(currentApiClient) } + launch { subscribeToSyncPlayGroupUpdates(currentApiClient) } } } @@ -261,4 +274,38 @@ constructor( _connectionState.value = WebSocketState.SERVER_SHUTDOWN scope.launch { disconnect() } } + + private suspend fun subscribeToSyncPlayCommands(apiClient: ApiClient) { + apiClient.webSocket + .subscribe(SyncPlayCommandMessage::class) + .catch { e -> Timber.e(e, "SyncPlay commands subscription failed") } + .collect { message -> + if (message.data == null) { + Timber.w( + "SyncPlay: SyncPlayCommandMessage received but data is null — SDK deserialization failed" + ) + } else { + Timber.d( + "SyncPlay: command received — type=${message.data!!.command}, ticks=${message.data!!.positionTicks}" + ) + _syncPlayCommands.emit(message.data!!) + } + } + } + + private suspend fun subscribeToSyncPlayGroupUpdates(apiClient: ApiClient) { + apiClient.webSocket + .subscribe(SyncPlayGroupUpdateCommandMessage::class) + .catch { e -> Timber.e(e, "SyncPlay group updates subscription failed") } + .collect { message -> + message.data?.let { groupUpdate -> + _syncPlayGroupUpdates.emit( + SyncPlayGroupUpdate( + type = groupUpdate.type, + groupId = groupUpdate.groupId, + ) + ) + } + } + } } diff --git a/app/src/main/java/com/makd/afinity/di/RepositoryModule.kt b/app/src/main/java/com/makd/afinity/di/RepositoryModule.kt index 551123fc..dd0560a3 100644 --- a/app/src/main/java/com/makd/afinity/di/RepositoryModule.kt +++ b/app/src/main/java/com/makd/afinity/di/RepositoryModule.kt @@ -21,6 +21,8 @@ import com.makd.afinity.data.repository.playback.JellyfinPlaybackRepository import com.makd.afinity.data.repository.playback.PlaybackRepository import com.makd.afinity.data.repository.server.JellyfinServerRepository import com.makd.afinity.data.repository.server.ServerRepository +import com.makd.afinity.data.repository.syncplay.JellyfinSyncPlayRepository +import com.makd.afinity.data.repository.syncplay.SyncPlayRepository import com.makd.afinity.data.repository.userdata.JellyfinUserDataRepository import com.makd.afinity.data.repository.userdata.UserDataRepository import dagger.Binds @@ -90,6 +92,12 @@ abstract class RepositoryModule { jellyfinDownloadRepository: JellyfinDownloadRepository ): DownloadRepository + @Binds + @Singleton + abstract fun bindSyncPlayRepository( + jellyfinSyncPlayRepository: JellyfinSyncPlayRepository + ): SyncPlayRepository + companion object { @Provides @Singleton diff --git a/app/src/main/java/com/makd/afinity/ui/player/PlayerScreen.kt b/app/src/main/java/com/makd/afinity/ui/player/PlayerScreen.kt index 00de855f..317906b6 100644 --- a/app/src/main/java/com/makd/afinity/ui/player/PlayerScreen.kt +++ b/app/src/main/java/com/makd/afinity/ui/player/PlayerScreen.kt @@ -27,6 +27,8 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp import androidx.compose.ui.viewinterop.AndroidView import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.media3.common.Player @@ -49,11 +51,15 @@ import com.makd.afinity.ui.player.components.MpvSurface import com.makd.afinity.ui.player.components.PlaybackStatsOverlay import com.makd.afinity.ui.player.components.PlayerControls import com.makd.afinity.ui.player.components.PlayerIndicators +import com.makd.afinity.ui.player.components.SyncPlayGroupSheet +import com.makd.afinity.ui.player.components.SyncPlayWaitingOverlay import com.makd.afinity.ui.player.components.TrickplayPreview import com.makd.afinity.ui.player.components.VersionPickerSheet import com.makd.afinity.ui.player.utils.KeepScreenOn import com.makd.afinity.ui.player.utils.PlayerSystemBarsController import com.makd.afinity.ui.player.utils.ScreenBrightnessController +import kotlinx.coroutines.flow.map +import org.jellyfin.sdk.model.api.GroupStateType import timber.log.Timber import java.util.UUID @@ -74,8 +80,11 @@ fun PlayerScreen( onBackPressed: () -> Unit, navController: NavController? = null, viewModel: PlayerViewModel = hiltViewModel(), + syncPlayViewModel: SyncPlayViewModel = hiltViewModel(), ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val syncPlayState by syncPlayViewModel.syncPlayState.collectAsStateWithLifecycle() + val syncPlayUiState by syncPlayViewModel.uiState.collectAsStateWithLifecycle() val playlistState by viewModel.playlistState.collectAsStateWithLifecycle(initialValue = PlaylistState()) @@ -123,12 +132,67 @@ fun PlayerScreen( ) } } + LaunchedEffect(Unit) { + viewModel.syncPlayInterceptor = SyncPlayInterceptor { event -> + syncPlayViewModel.handleLocalPlayerEvent(event) + } + syncPlayViewModel.setPlayerActions( + object : SyncPlayPlayerActions { + override fun executePlay() = viewModel.executeScheduledPlay() + + override fun executePause() = viewModel.executeScheduledPause() + + override fun executeSeek(positionMs: Long) = + viewModel.executeScheduledSeek(positionMs) + + override val currentPositionMs: Long + get() = viewModel.player.currentPosition + + override val currentIsPlaying: Boolean + get() = viewModel.player.isPlaying + + override val currentItemId: UUID? + get() = viewModel.currentPlayingItemId + } + ) + syncPlayViewModel.setBufferingFlow(viewModel.uiState.map { it.isBuffering }) + } + + LaunchedEffect(Unit) { + syncPlayViewModel.effects.collect { effect -> + when (effect) { + is SyncPlayEffect.LoadContent -> viewModel.handlePlayerEvent( + PlayerEvent.LoadMedia( + item = effect.item, + mediaSourceId = effect.mediaSourceId, + startPositionMs = effect.startPositionMs, + ) + ) + is SyncPlayEffect.GroupJoined -> syncPlayViewModel.dismissGroupSheet() + else -> {} + } + } + } + + val lifecycleOwner = LocalLifecycleOwner.current + DisposableEffect(lifecycleOwner) { + val observer = LifecycleEventObserver { _, event -> + when (event) { + Lifecycle.Event.ON_PAUSE -> syncPlayViewModel.onAppBackground() + Lifecycle.Event.ON_RESUME -> syncPlayViewModel.onAppForeground() + else -> {} + } + } + lifecycleOwner.lifecycle.addObserver(observer) + onDispose { lifecycleOwner.lifecycle.removeObserver(observer) } + } var hasNavigatedBack by remember { mutableStateOf(false) } BackHandler { if (!hasNavigatedBack) { hasNavigatedBack = true + if (syncPlayState.isInGroup) syncPlayViewModel.leaveGroup() viewModel.stopPlayback() onBackPressed() } @@ -293,6 +357,7 @@ fun PlayerScreen( onBackClick = { if (!hasNavigatedBack) { hasNavigatedBack = true + if (syncPlayState.isInGroup) syncPlayViewModel.leaveGroup() viewModel.stopPlayback() onBackPressed() } @@ -305,8 +370,14 @@ fun PlayerScreen( playlistContentStartIndex = playlistState.contentStartIndex, onJumpToEpisode = viewModel::jumpToEpisode, onVersionToggleRequest = { showVersionPicker = !showVersionPicker }, + isSyncPlay = syncPlayState.isInGroup, + onSyncPlayClick = { syncPlayViewModel.toggleGroupSheet() }, ) + if (syncPlayState.isInGroup && syncPlayState.groupState == GroupStateType.WAITING) { + SyncPlayWaitingOverlay(modifier = Modifier.fillMaxSize()) + } + TrickplayPreview( isVisible = uiState.showTrickplayPreview, previewImage = uiState.trickplayPreviewImage, @@ -342,7 +413,6 @@ fun PlayerScreen( ) } - // Version picker — rendered here so align(BottomEnd) maps to the actual screen Box if (showVersionPicker && uiState.availableSources.size > 1) { Box( modifier = @@ -379,6 +449,18 @@ fun PlayerScreen( } } + if (syncPlayUiState.showGroupSheet) { + SyncPlayGroupSheet( + syncPlayState = syncPlayState, + uiState = syncPlayUiState, + onCreateGroup = { name -> syncPlayViewModel.createGroup(name) }, + onJoinGroup = { id -> syncPlayViewModel.joinGroup(id) }, + onLeaveGroup = { syncPlayViewModel.leaveGroup() }, + onRefreshGroups = { syncPlayViewModel.loadGroups() }, + onDismiss = { syncPlayViewModel.dismissGroupSheet() }, + ) + } + ScreenBrightnessController(brightness = uiState.brightnessLevel) KeepScreenOn(keepOn = uiState.isPlaying) PlayerSystemBarsController(isControlsVisible = uiState.showControls) diff --git a/app/src/main/java/com/makd/afinity/ui/player/PlayerViewModel.kt b/app/src/main/java/com/makd/afinity/ui/player/PlayerViewModel.kt index 1eddfb2a..2facc062 100644 --- a/app/src/main/java/com/makd/afinity/ui/player/PlayerViewModel.kt +++ b/app/src/main/java/com/makd/afinity/ui/player/PlayerViewModel.kt @@ -111,6 +111,8 @@ constructor( lateinit var player: Player private set + var syncPlayInterceptor: SyncPlayInterceptor? = null + private var hasStoppedPlayback = false private var currentSessionId: String? = null private var currentLivePlaybackInfo: LiveTvPlaybackInfo? = null @@ -138,6 +140,7 @@ constructor( private var pendingAudioTrackPosition: Int? = null private var pendingSubtitleTrackPosition: Int? = null private var currentItem: AfinityItem? = null + val currentPlayingItemId: java.util.UUID? get() = currentItem?.id private var currentTrickplayInfo: AfinityTrickplayInfo? = null private var currentTrickplayItemId: UUID? = null private val trickplayTileCache = @@ -720,6 +723,7 @@ constructor( fun handlePlayerEvent(event: PlayerEvent) { viewModelScope.launch { + if (syncPlayInterceptor?.handle(event) == true) return@launch when (event) { is PlayerEvent.Play -> player.play() is PlayerEvent.Pause -> player.pause() @@ -894,6 +898,18 @@ constructor( } } + fun executeScheduledPlay() { + player.play() + } + + fun executeScheduledPause() { + player.pause() + } + + fun executeScheduledSeek(positionMs: Long) { + player.seekTo(positionMs) + } + private fun startStatsPolling() { statsPollingJob?.cancel() statsPollingJob = viewModelScope.launch { diff --git a/app/src/main/java/com/makd/afinity/ui/player/SyncPlayViewModel.kt b/app/src/main/java/com/makd/afinity/ui/player/SyncPlayViewModel.kt new file mode 100644 index 00000000..18c42ee5 --- /dev/null +++ b/app/src/main/java/com/makd/afinity/ui/player/SyncPlayViewModel.kt @@ -0,0 +1,410 @@ +package com.makd.afinity.ui.player + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.makd.afinity.data.models.media.AfinityItem +import com.makd.afinity.data.models.player.PlayerEvent +import com.makd.afinity.data.models.syncplay.SyncPlayState +import com.makd.afinity.data.repository.media.MediaRepository +import com.makd.afinity.data.repository.syncplay.SyncPlayRepository +import com.makd.afinity.data.syncplay.SyncPlayGroupEvent +import com.makd.afinity.data.syncplay.SyncPlayGroupUpdate +import com.makd.afinity.data.syncplay.SyncPlayRawWebSocket +import com.makd.afinity.data.syncplay.SyncPlayTimeSyncEngine +import com.makd.afinity.data.websocket.JellyfinWebSocketManager +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import org.jellyfin.sdk.model.UUID +import org.jellyfin.sdk.model.api.GroupInfoDto +import org.jellyfin.sdk.model.api.GroupUpdateType +import org.jellyfin.sdk.model.api.PlayQueueUpdate +import org.jellyfin.sdk.model.api.SendCommand +import org.jellyfin.sdk.model.api.SendCommandType +import timber.log.Timber +import javax.inject.Inject + +fun interface SyncPlayInterceptor { + fun handle(event: PlayerEvent): Boolean +} + +interface SyncPlayPlayerActions { + fun executePlay() + + fun executePause() + + fun executeSeek(positionMs: Long) + + val currentPositionMs: Long + + val currentIsPlaying: Boolean + + val currentItemId: UUID? +} + +data class SyncPlayUiState( + val availableGroups: List = emptyList(), + val isLoadingGroups: Boolean = false, + val showGroupSheet: Boolean = false, + val error: String? = null, + val isJoining: Boolean = false, +) + +sealed class SyncPlayEffect { + data object GroupJoined : SyncPlayEffect() + + data object GroupLeft : SyncPlayEffect() + + data class ShowError(val message: String) : SyncPlayEffect() + + data class LoadContent( + val item: AfinityItem, + val mediaSourceId: String, + val startPositionMs: Long, + ) : SyncPlayEffect() +} + +@HiltViewModel +class SyncPlayViewModel +@Inject +constructor( + private val syncPlayRepository: SyncPlayRepository, + private val webSocketManager: JellyfinWebSocketManager, + private val timeSyncEngine: SyncPlayTimeSyncEngine, + private val rawWebSocket: SyncPlayRawWebSocket, + private val mediaRepository: MediaRepository, +) : ViewModel() { + val syncPlayState: StateFlow = syncPlayRepository.syncPlayState + + private val _uiState = MutableStateFlow(SyncPlayUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + private val _effects = MutableSharedFlow(extraBufferCapacity = 8) + val effects: SharedFlow = _effects.asSharedFlow() + + private var playerActions: SyncPlayPlayerActions? = null + + private var currentPlaylistItemId: UUID? = null + + private var scheduledCommandJob: Job? = null + private var bufferingFlowJob: Job? = null + + init { + viewModelScope.launch { collectSyncPlayCommands() } + viewModelScope.launch { collectRawCommands() } + viewModelScope.launch { collectGroupUpdates() } + viewModelScope.launch { collectRawGroupEvents() } + viewModelScope.launch { collectPlayQueueUpdates() } + } + + fun setPlayerActions(actions: SyncPlayPlayerActions) { + playerActions = actions + } + + fun setBufferingFlow(flow: Flow) { + bufferingFlowJob?.cancel() + bufferingFlowJob = viewModelScope.launch { + flow.collect { isBuffering -> onBufferingStateChanged(isBuffering) } + } + } + + fun handleLocalPlayerEvent(event: PlayerEvent): Boolean { + if (!syncPlayRepository.syncPlayState.value.isInGroup) return false + return when (event) { + is PlayerEvent.Play -> { + viewModelScope.launch { syncPlayRepository.unpause() } + true + } + is PlayerEvent.Pause -> { + viewModelScope.launch { syncPlayRepository.pause() } + true + } + is PlayerEvent.Seek -> { + viewModelScope.launch { syncPlayRepository.seek(event.positionMs * 10_000L) } + true + } + else -> false + } + } + + fun loadGroups() { + viewModelScope.launch { + _uiState.update { it.copy(isLoadingGroups = true, error = null) } + val groups = syncPlayRepository.getGroups() + _uiState.update { it.copy(availableGroups = groups, isLoadingGroups = false) } + } + } + + fun createGroup(name: String) { + viewModelScope.launch { + _uiState.update { it.copy(error = null, isJoining = true) } + rawWebSocket.start() + syncPlayRepository.createGroup(name) + val itemId = playerActions?.currentItemId ?: return@launch + val positionMs = playerActions?.currentPositionMs ?: 0L + syncPlayRepository.setNewQueue( + itemIds = listOf(itemId), + position = 0, + startPositionTicks = positionMs * 10_000L, + ) + } + } + + fun joinGroup(groupId: UUID) { + viewModelScope.launch { + _uiState.update { it.copy(error = null, isJoining = true) } + rawWebSocket.start() + syncPlayRepository.joinGroup(groupId) + } + } + + fun leaveGroup() { + viewModelScope.launch { + rawWebSocket.stop() + syncPlayRepository.leaveGroup() + timeSyncEngine.stop() + currentPlaylistItemId = null + _effects.emit(SyncPlayEffect.GroupLeft) + } + } + + fun toggleGroupSheet() { + val willOpen = !_uiState.value.showGroupSheet + _uiState.update { it.copy(showGroupSheet = willOpen) } + if (willOpen) { + rawWebSocket.start() + loadGroups() + } + } + + fun dismissGroupSheet() { + _uiState.update { it.copy(showGroupSheet = false, isJoining = false) } + } + + fun onAppBackground() { + if (!syncPlayRepository.syncPlayState.value.isInGroup) return + val playlistItemId = currentPlaylistItemId ?: return + val positionMs = playerActions?.currentPositionMs ?: 0L + viewModelScope.launch { + syncPlayRepository.reportBuffering( + positionTicks = positionMs * 10_000L, + isPlaying = false, + playlistItemId = playlistItemId, + ) + } + } + + fun onAppForeground() { + if (!syncPlayRepository.syncPlayState.value.isInGroup) return + viewModelScope.launch { + timeSyncEngine.syncOnJoin() + val playlistItemId = currentPlaylistItemId ?: return@launch + val positionMs = playerActions?.currentPositionMs ?: 0L + syncPlayRepository.reportReady( + positionTicks = positionMs * 10_000L, + isPlaying = playerActions?.currentIsPlaying ?: false, + playlistItemId = playlistItemId, + ) + } + } + + private suspend fun collectSyncPlayCommands() { + webSocketManager.syncPlayCommands + .catch { e -> Timber.e(e, "Error collecting SyncPlay commands (SDK path)") } + .collect { command -> handleSyncPlayCommand(command) } + } + + private suspend fun collectRawCommands() { + rawWebSocket.commands + .catch { e -> Timber.e(e, "Error collecting SyncPlay commands (raw WS path)") } + .collect { command -> handleSyncPlayCommand(command) } + } + + private suspend fun collectGroupUpdates() { + webSocketManager.syncPlayGroupUpdates + .catch { e -> Timber.e(e, "Error collecting SyncPlay group updates") } + .collect { update -> handleGroupUpdate(update) } + } + + private suspend fun collectRawGroupEvents() { + rawWebSocket.groupEvents + .catch { e -> Timber.e(e, "Error collecting SyncPlay raw group events") } + .collect { event -> + syncPlayRepository.updateFromGroupEvent(event) + when (event) { + is SyncPlayGroupEvent.GroupStateRefreshed -> { + timeSyncEngine.syncOnJoin() + _uiState.update { it.copy(isJoining = false) } + _effects.emit(SyncPlayEffect.GroupJoined) + } + is SyncPlayGroupEvent.GroupLeft -> { + timeSyncEngine.stop() + rawWebSocket.stop() + currentPlaylistItemId = null + _effects.emit(SyncPlayEffect.GroupLeft) + } + else -> {} + } + } + } + + private suspend fun collectPlayQueueUpdates() { + rawWebSocket.playQueueUpdates + .catch { e -> Timber.e(e, "Error collecting SyncPlay queue updates") } + .collect { update -> handlePlayQueueUpdate(update) } + } + + private suspend fun handlePlayQueueUpdate(update: PlayQueueUpdate) { + val groupId = syncPlayRepository.syncPlayState.value.groupId ?: return + + syncPlayRepository.updateFromGroupEvent( + SyncPlayGroupEvent.QueueChanged(groupId = groupId, update = update) + ) + + val playingItem = update.playlist.getOrNull(update.playingItemIndex) ?: return + + currentPlaylistItemId = playingItem.playlistItemId + + val startPositionMs = update.startPositionTicks / 10_000L + + val alreadyPlaying = playerActions?.currentItemId == playingItem.itemId + if (alreadyPlaying) { + playerActions?.executeSeek(startPositionMs) + syncPlayRepository.reportReady( + positionTicks = startPositionMs * 10_000L, + isPlaying = playerActions?.currentIsPlaying ?: false, + playlistItemId = playingItem.playlistItemId, + ) + Timber.d( + "SyncPlay: already playing ${playingItem.itemId}, synced to ${startPositionMs}ms" + ) + return + } + + try { + val item = + mediaRepository.getItemById(playingItem.itemId) + ?: run { + Timber.w("SyncPlay: could not load item ${playingItem.itemId}") + return + } + val mediaSourceId = + item.sources.firstOrNull()?.id + ?: run { + Timber.w("SyncPlay: item ${playingItem.itemId} has no sources") + return + } + Timber.d("SyncPlay: loading ${item.name} at ${startPositionMs}ms") + _effects.emit(SyncPlayEffect.LoadContent(item, mediaSourceId, startPositionMs)) + } catch (e: Exception) { + Timber.e(e, "SyncPlay: failed to load item ${playingItem.itemId}") + } + } + + private suspend fun handleSyncPlayCommand(command: SendCommand) { + currentPlaylistItemId = command.playlistItemId + val rawDelayMs = timeSyncEngine.toScheduledDelayMs(command.`when`) + val delayMs = rawDelayMs.coerceIn(0L, 3_000L) + Timber.d( + "SyncPlay command: ${command.command}, rawDelayMs=$rawDelayMs, clampedDelayMs=$delayMs, ticks=${command.positionTicks}, clockOffset=${timeSyncEngine.clockOffsetMs}ms" + ) + if (rawDelayMs > 3_000L) { + Timber.w( + "SyncPlay: rawDelayMs=$rawDelayMs clamped to 3000ms — likely clock skew. Check time sync." + ) + } + + scheduledCommandJob?.cancel() + scheduledCommandJob = viewModelScope.launch { + if (delayMs > 0) delay(delayMs) + executeCommand(command) + } + } + + private fun executeCommand(command: SendCommand) { + val actions = playerActions ?: return + when (command.command) { + SendCommandType.UNPAUSE -> actions.executePlay() + SendCommandType.PAUSE -> actions.executePause() + SendCommandType.SEEK -> { + val positionMs = (command.positionTicks ?: 0L) / 10_000L + actions.executeSeek(positionMs) + } + SendCommandType.STOP -> actions.executePause() + } + } + + private suspend fun handleGroupUpdate(update: SyncPlayGroupUpdate) { + Timber.d("SyncPlay SDK update: type=${update.type}, groupId=${update.groupId}") + when (update.type) { + GroupUpdateType.GROUP_JOINED -> { + if (_uiState.value.isJoining) { + syncPlayRepository.setGroupJoined(update.groupId) + timeSyncEngine.syncOnJoin() + _uiState.update { it.copy(isJoining = false) } + _effects.emit(SyncPlayEffect.GroupJoined) + Timber.w("SyncPlay: GROUP_JOINED received via SDK fallback (raw WS was late)") + } + } + GroupUpdateType.GROUP_LEFT, + GroupUpdateType.NOT_IN_GROUP, + GroupUpdateType.GROUP_DOES_NOT_EXIST -> { + if (syncPlayRepository.syncPlayState.value.isInGroup) { + syncPlayRepository.updateFromGroupEvent( + SyncPlayGroupEvent.GroupLeft(update.groupId) + ) + timeSyncEngine.stop() + rawWebSocket.stop() + currentPlaylistItemId = null + _effects.emit(SyncPlayEffect.GroupLeft) + } + } + GroupUpdateType.CREATE_GROUP_DENIED, + GroupUpdateType.JOIN_GROUP_DENIED, + GroupUpdateType.LIBRARY_ACCESS_DENIED -> { + val message = "Access denied: ${update.type.serialName}" + syncPlayRepository.updateFromGroupEvent( + SyncPlayGroupEvent.Error(update.type, message) + ) + _uiState.update { it.copy(error = message, isJoining = false) } + _effects.emit(SyncPlayEffect.ShowError(message)) + } + else -> {} + } + } + + private suspend fun onBufferingStateChanged(isBuffering: Boolean) { + if (!syncPlayRepository.syncPlayState.value.isInGroup) return + val playlistItemId = currentPlaylistItemId ?: return + val positionMs = playerActions?.currentPositionMs ?: 0L + if (isBuffering) { + syncPlayRepository.reportBuffering( + positionTicks = positionMs * 10_000L, + isPlaying = false, + playlistItemId = playlistItemId, + ) + } else { + syncPlayRepository.reportReady( + positionTicks = positionMs * 10_000L, + isPlaying = playerActions?.currentIsPlaying ?: false, + playlistItemId = playlistItemId, + ) + } + } + + override fun onCleared() { + super.onCleared() + rawWebSocket.stop() + timeSyncEngine.stop() + } +} diff --git a/app/src/main/java/com/makd/afinity/ui/player/components/PlayerControls.kt b/app/src/main/java/com/makd/afinity/ui/player/components/PlayerControls.kt index e3c5e998..c2a33a16 100644 --- a/app/src/main/java/com/makd/afinity/ui/player/components/PlayerControls.kt +++ b/app/src/main/java/com/makd/afinity/ui/player/components/PlayerControls.kt @@ -110,6 +110,8 @@ fun PlayerControls( playlistContentStartIndex: Int = 0, onJumpToEpisode: (java.util.UUID) -> Unit = {}, onVersionToggleRequest: () -> Unit = {}, + isSyncPlay: Boolean = false, + onSyncPlayClick: () -> Unit = {}, ) { var showAudioSelector by remember { mutableStateOf(false) } var showSubtitleSelector by remember { mutableStateOf(false) } @@ -360,6 +362,8 @@ fun PlayerControls( onBackClick = onBackClick, onLockToggle = { onPlayerEvent(PlayerEvent.ToggleLock) }, onPipToggle = onPipToggle, + isSyncPlay = isSyncPlay, + onSyncPlayClick = onSyncPlayClick, ) if (!uiState.isControlsLocked && !uiState.isInPictureInPictureMode) { @@ -676,7 +680,9 @@ private fun TopControls( onPlayerEvent: (PlayerEvent) -> Unit, onBackClick: () -> Unit, onLockToggle: () -> Unit, - onPipToggle: () -> Unit = { /* TODO */ }, + onPipToggle: () -> Unit = {}, + isSyncPlay: Boolean = false, + onSyncPlayClick: () -> Unit = {}, ) { Box( modifier = @@ -727,6 +733,19 @@ private fun TopControls( } if (!uiState.isControlsLocked && !uiState.isInPictureInPictureMode) { + IconButton( + onClick = onSyncPlayClick, + modifier = Modifier.size(40.dp), + ) { + Icon( + painter = painterResource(id = R.drawable.ic_users_group), + contentDescription = "Watch party", + tint = + if (isSyncPlay) MaterialTheme.colorScheme.primary else Color.White, + modifier = Modifier.size(24.dp), + ) + } + IconButton( onClick = { onPlayerEvent(PlayerEvent.RequestCastDeviceSelection) }, modifier = Modifier.size(40.dp), diff --git a/app/src/main/java/com/makd/afinity/ui/player/components/SyncPlayGroupSheet.kt b/app/src/main/java/com/makd/afinity/ui/player/components/SyncPlayGroupSheet.kt new file mode 100644 index 00000000..bce5477b --- /dev/null +++ b/app/src/main/java/com/makd/afinity/ui/player/components/SyncPlayGroupSheet.kt @@ -0,0 +1,339 @@ +package com.makd.afinity.ui.player.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.SuggestionChip +import androidx.compose.material3.SuggestionChipDefaults +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import com.makd.afinity.data.models.syncplay.SyncPlayState +import com.makd.afinity.ui.player.SyncPlayUiState +import org.jellyfin.sdk.model.UUID +import org.jellyfin.sdk.model.api.GroupInfoDto +import org.jellyfin.sdk.model.api.GroupStateType + +@Composable +fun SyncPlayGroupSheet( + syncPlayState: SyncPlayState, + uiState: SyncPlayUiState, + onCreateGroup: (name: String) -> Unit, + onJoinGroup: (groupId: UUID) -> Unit, + onLeaveGroup: () -> Unit, + onRefreshGroups: () -> Unit, + onDismiss: () -> Unit, +) { + Dialog( + onDismissRequest = { if (!uiState.isJoining) onDismiss() }, + properties = DialogProperties(usePlatformDefaultWidth = false), + ) { + Surface( + modifier = Modifier.widthIn(max = 440.dp).fillMaxWidth(0.9f).padding(vertical = 24.dp), + shape = RoundedCornerShape(28.dp), + color = MaterialTheme.colorScheme.surfaceContainerHigh, + ) { + if (syncPlayState.isInGroup) { + InGroupContent( + syncPlayState = syncPlayState, + onLeaveGroup = { + onLeaveGroup() + onDismiss() + }, + ) + } else { + NotInGroupContent( + uiState = uiState, + onCreateGroup = onCreateGroup, + onJoinGroup = onJoinGroup, + onRefreshGroups = onRefreshGroups, + onDismiss = onDismiss, + ) + } + } + } +} + +@Composable +private fun InGroupContent( + syncPlayState: SyncPlayState, + onLeaveGroup: () -> Unit, +) { + Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp, vertical = 16.dp)) { + Text( + text = "Watch Party", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + ) + + Spacer(modifier = Modifier.height(12.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = syncPlayState.groupName, + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Medium, + ) + GroupStateChip(state = syncPlayState.groupState) + } + + Spacer(modifier = Modifier.height(4.dp)) + + Text( + text = + "${syncPlayState.members.size} member${if (syncPlayState.members.size == 1) "" else "s"}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f), + ) + + if (syncPlayState.members.isNotEmpty()) { + Spacer(modifier = Modifier.height(12.dp)) + HorizontalDivider() + Spacer(modifier = Modifier.height(8.dp)) + + LazyColumn(modifier = Modifier.fillMaxWidth()) { + items(syncPlayState.members) { member -> + Text( + text = member, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(vertical = 6.dp), + ) + } + } + } + + Spacer(modifier = Modifier.height(20.dp)) + + Button( + onClick = onLeaveGroup, + colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error), + modifier = Modifier.fillMaxWidth(), + ) { + Text(text = "Leave group") + } + + Spacer(modifier = Modifier.height(16.dp)) + } +} + +@Composable +private fun NotInGroupContent( + uiState: SyncPlayUiState, + onCreateGroup: (name: String) -> Unit, + onJoinGroup: (groupId: UUID) -> Unit, + onRefreshGroups: () -> Unit, + onDismiss: () -> Unit, +) { + var groupName by remember { mutableStateOf("") } + val isJoining = uiState.isJoining + + Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp, vertical = 16.dp)) { + Text( + text = "Watch Party", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + ) + + Spacer(modifier = Modifier.height(16.dp)) + + OutlinedTextField( + value = groupName, + onValueChange = { groupName = it }, + label = { Text("Group name") }, + singleLine = true, + enabled = !isJoining, + modifier = Modifier.fillMaxWidth(), + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Button( + onClick = { + if (groupName.isNotBlank()) onCreateGroup(groupName.trim()) + }, + enabled = groupName.isNotBlank() && !isJoining, + modifier = Modifier.fillMaxWidth(), + ) { + if (isJoining) { + CircularProgressIndicator( + modifier = Modifier.size(18.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.onPrimary, + ) + Spacer(modifier = Modifier.width(8.dp)) + Text(text = "Joining…") + } else { + Text(text = "Create group") + } + } + + if (uiState.error != null) { + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = uiState.error, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + ) + } + + Spacer(modifier = Modifier.height(20.dp)) + HorizontalDivider() + Spacer(modifier = Modifier.height(12.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = "Active groups", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f), + ) + TextButton(onClick = onRefreshGroups, enabled = !isJoining) { + Text(text = "Refresh") + } + } + + if (uiState.isLoadingGroups) { + Box( + modifier = Modifier.fillMaxWidth().padding(vertical = 16.dp), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator(modifier = Modifier.size(24.dp)) + } + } else if (uiState.availableGroups.isEmpty()) { + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "No active groups", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f), + modifier = Modifier.padding(vertical = 8.dp), + ) + } else { + LazyColumn(modifier = Modifier.fillMaxWidth()) { + items(uiState.availableGroups, key = { it.groupId }) { group -> + GroupRow( + group = group, + isJoining = isJoining, + onJoinGroup = onJoinGroup, + ) + } + } + } + + Spacer(modifier = Modifier.height(8.dp)) + + TextButton( + onClick = onDismiss, + enabled = !isJoining, + modifier = Modifier.align(Alignment.End), + ) { + Text(text = "Cancel") + } + + Spacer(modifier = Modifier.height(8.dp)) + } +} + +@Composable +private fun GroupRow( + group: GroupInfoDto, + isJoining: Boolean, + onJoinGroup: (UUID) -> Unit, +) { + Row( + modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = group.groupName, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + ) + Text( + text = + "${group.participants.size} member${if (group.participants.size == 1) "" else "s"}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f), + ) + } + Spacer(modifier = Modifier.width(8.dp)) + GroupStateChip(state = group.state) + Spacer(modifier = Modifier.width(8.dp)) + FilledTonalButton( + onClick = { onJoinGroup(group.groupId) }, + enabled = !isJoining, + ) { + if (isJoining) { + CircularProgressIndicator( + modifier = Modifier.size(16.dp), + strokeWidth = 2.dp, + ) + } else { + Text(text = "Join") + } + } + } +} + +@Composable +private fun GroupStateChip(state: GroupStateType) { + val (label, color) = + when (state) { + GroupStateType.IDLE -> "Ready" to MaterialTheme.colorScheme.secondary + GroupStateType.WAITING -> "Waiting…" to MaterialTheme.colorScheme.tertiary + GroupStateType.PAUSED -> "Paused" to MaterialTheme.colorScheme.outline + GroupStateType.PLAYING -> "Playing" to MaterialTheme.colorScheme.primary + else -> "Unknown" to MaterialTheme.colorScheme.outline + } + SuggestionChip( + onClick = {}, + label = { Text(text = label, style = MaterialTheme.typography.labelSmall) }, + colors = + SuggestionChipDefaults.suggestionChipColors( + containerColor = color.copy(alpha = 0.15f), + labelColor = color, + ), + border = + SuggestionChipDefaults.suggestionChipBorder( + enabled = true, + borderColor = color.copy(alpha = 0.4f), + ), + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/makd/afinity/ui/player/components/SyncPlayWaitingOverlay.kt b/app/src/main/java/com/makd/afinity/ui/player/components/SyncPlayWaitingOverlay.kt new file mode 100644 index 00000000..c6ed3a44 --- /dev/null +++ b/app/src/main/java/com/makd/afinity/ui/player/components/SyncPlayWaitingOverlay.kt @@ -0,0 +1,46 @@ +package com.makd.afinity.ui.player.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.size +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp + +@Composable +fun SyncPlayWaitingOverlay(modifier: Modifier = Modifier) { + Box( + modifier = modifier + .fillMaxSize() + .background(Color.Black.copy(alpha = 0.75f)), + contentAlignment = Alignment.Center, + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + CircularProgressIndicator( + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(40.dp), + ) + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = "Waiting for other members…", + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Medium, + color = Color.White, + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_users_group.xml b/app/src/main/res/drawable/ic_users_group.xml new file mode 100644 index 00000000..45099640 --- /dev/null +++ b/app/src/main/res/drawable/ic_users_group.xml @@ -0,0 +1,42 @@ + + + + + + + + \ No newline at end of file From 83c4ee64566c33fb93b4102e256e5636ea1a47d7 Mon Sep 17 00:00:00 2001 From: M0RPH3US Date: Tue, 2 Jun 2026 01:00:53 +0530 Subject: [PATCH 2/2] Refactor Discord webhook action for improved security Refactor Discord webhook action to use jq for secure data extraction from event JSON. Improved handling of empty descriptions and added debugging output for failed notifications. --- .github/workflows/discord-activity.yml | 59 ++++++++++++-------------- 1 file changed, 28 insertions(+), 31 deletions(-) diff --git a/.github/workflows/discord-activity.yml b/.github/workflows/discord-activity.yml index 23056b6f..ec789127 100644 --- a/.github/workflows/discord-activity.yml +++ b/.github/workflows/discord-activity.yml @@ -15,30 +15,23 @@ jobs: DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }} run: | EVENT_TYPE="${{ github.event_name }}" - ACTION="${{ github.event.action }}" REPO_NAME="${{ github.repository }}" REPO_URL="https://github.com/${{ github.repository }}" TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ") - TITLE="" - DESCRIPTION="" - URL="" - AUTHOR_NAME="" - AUTHOR_ICON="" - AUTHOR_URL="" - COLOR=0 - STATUS_TEXT="" - LABELS_RAW="" + # 1. Read the action securely from the event JSON + ACTION=$(jq -r '.action' "$GITHUB_EVENT_PATH") + # 2. Extract specific variables based on event type securely if [ "$EVENT_TYPE" == "issues" ]; then - TITLE="${{ github.event.issue.title }}" - NUMBER="${{ github.event.issue.number }}" - URL="${{ github.event.issue.html_url }}" - DESCRIPTION="${{ github.event.issue.body }}" - AUTHOR_NAME="${{ github.event.issue.user.login }}" - AUTHOR_ICON="${{ github.event.issue.user.avatar_url }}" - AUTHOR_URL="${{ github.event.issue.user.html_url }}" - LABELS_RAW='${{ toJson(github.event.issue.labels) }}' + TITLE=$(jq -r '.issue.title' "$GITHUB_EVENT_PATH") + NUMBER=$(jq -r '.issue.number' "$GITHUB_EVENT_PATH") + URL=$(jq -r '.issue.html_url' "$GITHUB_EVENT_PATH") + DESCRIPTION=$(jq -r '.issue.body // ""' "$GITHUB_EVENT_PATH") + AUTHOR_NAME=$(jq -r '.issue.user.login' "$GITHUB_EVENT_PATH") + AUTHOR_ICON=$(jq -r '.issue.user.avatar_url' "$GITHUB_EVENT_PATH") + AUTHOR_URL=$(jq -r '.issue.user.html_url' "$GITHUB_EVENT_PATH") + LABELS_STRING=$(jq -r 'if (.issue.labels | length) > 0 then .issue.labels | map("`" + .name + "`") | join(" ") else "None" end' "$GITHUB_EVENT_PATH") if [ "$ACTION" == "opened" ]; then STATUS_TEXT="Issue Opened" @@ -49,20 +42,20 @@ jobs: fi elif [ "$EVENT_TYPE" == "pull_request" ]; then - TITLE="${{ github.event.pull_request.title }}" - NUMBER="${{ github.event.pull_request.number }}" - URL="${{ github.event.pull_request.html_url }}" - DESCRIPTION="${{ github.event.pull_request.body }}" - AUTHOR_NAME="${{ github.event.pull_request.user.login }}" - AUTHOR_ICON="${{ github.event.pull_request.user.avatar_url }}" - AUTHOR_URL="${{ github.event.pull_request.user.html_url }}" - LABELS_RAW='${{ toJson(github.event.pull_request.labels) }}' + TITLE=$(jq -r '.pull_request.title' "$GITHUB_EVENT_PATH") + NUMBER=$(jq -r '.pull_request.number' "$GITHUB_EVENT_PATH") + URL=$(jq -r '.pull_request.html_url' "$GITHUB_EVENT_PATH") + DESCRIPTION=$(jq -r '.pull_request.body // ""' "$GITHUB_EVENT_PATH") + AUTHOR_NAME=$(jq -r '.pull_request.user.login' "$GITHUB_EVENT_PATH") + AUTHOR_ICON=$(jq -r '.pull_request.user.avatar_url' "$GITHUB_EVENT_PATH") + AUTHOR_URL=$(jq -r '.pull_request.user.html_url' "$GITHUB_EVENT_PATH") + LABELS_STRING=$(jq -r 'if (.pull_request.labels | length) > 0 then .pull_request.labels | map("`" + .name + "`") | join(" ") else "None" end' "$GITHUB_EVENT_PATH") if [ "$ACTION" == "opened" ]; then STATUS_TEXT="Pull Request Opened" COLOR=5793266 elif [ "$ACTION" == "closed" ]; then - IS_MERGED="${{ github.event.pull_request.merged }}" + IS_MERGED=$(jq -r '.pull_request.merged' "$GITHUB_EVENT_PATH") if [ "$IS_MERGED" == "true" ]; then STATUS_TEXT="Pull Request Merged" COLOR=10181046 @@ -73,19 +66,19 @@ jobs: fi fi - LABELS_STRING=$(echo "$LABELS_RAW" | jq -r 'if length > 0 then map("`" + .name + "`") | join(" ") else "None" end') - - MAX_DESC_LENGTH=2000 - if [ -z "$DESCRIPTION" ]; then + # 3. Handle Empty Descriptions & Truncation + if [ -z "$DESCRIPTION" ] || [ "$DESCRIPTION" == "null" ]; then DESCRIPTION="No description provided." fi + MAX_DESC_LENGTH=2000 if [ ${#DESCRIPTION} -gt $MAX_DESC_LENGTH ]; then DESCRIPTION_TRUNCATED="${DESCRIPTION:0:$MAX_DESC_LENGTH}... [Read more]($URL)" else DESCRIPTION_TRUNCATED="$DESCRIPTION" fi + # 4. Generate Discord JSON Payload DISCORD_PAYLOAD=$(jq -n \ --arg title "$STATUS_TEXT: #$NUMBER $TITLE" \ --arg description "$DESCRIPTION_TRUNCATED" \ @@ -139,6 +132,7 @@ jobs: }] }') + # 5. Send Webhook RESPONSE=$(curl -s -w "\n%{http_code}" -H "Content-Type: application/json" \ -d "$DISCORD_PAYLOAD" \ "$DISCORD_WEBHOOK_URL") @@ -148,5 +142,8 @@ jobs: echo "Notification sent successfully." else echo "Failed to send notification. HTTP Code: $HTTP_CODE" + # Added this to print out Discord's error messages for easier future debugging + echo "Response body: $RESPONSE" exit 1 fi +