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 + 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