Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
184 changes: 169 additions & 15 deletions stream-video-android-core/api/stream-video-android-core.api

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import io.getstream.video.android.core.call.audio.InputAudioFilter
import io.getstream.video.android.core.call.utils.SoundInputProcessor
import io.getstream.video.android.core.call.video.VideoFilter
import io.getstream.video.android.core.call.video.YuvFrame
import io.getstream.video.android.core.closedcaptions.ClosedCaptionsSettings
import io.getstream.video.android.core.events.GoAwayEvent
import io.getstream.video.android.core.events.JoinCallResponseEvent
import io.getstream.video.android.core.events.VideoEventListener
Expand Down Expand Up @@ -78,7 +79,9 @@ import org.openapitools.client.models.PinResponse
import org.openapitools.client.models.RejectCallResponse
import org.openapitools.client.models.SendCallEventResponse
import org.openapitools.client.models.SendReactionResponse
import org.openapitools.client.models.StartClosedCaptionResponse
import org.openapitools.client.models.StartTranscriptionResponse
import org.openapitools.client.models.StopClosedCaptionResponse
import org.openapitools.client.models.StopLiveResponse
import org.openapitools.client.models.StopTranscriptionResponse
import org.openapitools.client.models.UnpinResponse
Expand Down Expand Up @@ -1297,6 +1300,18 @@ public class Call(
return clientImpl.listTranscription(type, id)
}

suspend fun startClosedCaptions(): Result<StartClosedCaptionResponse> {
return clientImpl.startClosedCaptions(type, id)
}

suspend fun stopClosedCaptions(): Result<StopClosedCaptionResponse> {
return clientImpl.stopClosedCaptions(type, id)
}

fun updateClosedCaptionsSettings(closedCaptionsSettings: ClosedCaptionsSettings) {
state.closedCaptionManager.updateClosedCaptionsSettings(closedCaptionsSettings)
}

/**
* Sets the preferred incoming video resolution.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ import android.util.Log
import androidx.compose.runtime.Stable
import io.getstream.log.taggedLogger
import io.getstream.video.android.core.call.RtcSession
import io.getstream.video.android.core.closedcaptions.ClosedCaptionManager
import io.getstream.video.android.core.closedcaptions.ClosedCaptionsSettings
import io.getstream.video.android.core.events.AudioLevelChangedEvent
import io.getstream.video.android.core.events.ChangePublishQualityEvent
import io.getstream.video.android.core.events.ConnectionQualityChangeEvent
Expand Down Expand Up @@ -72,6 +74,7 @@ import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import org.openapitools.client.models.BlockedUserEvent
import org.openapitools.client.models.CallAcceptedEvent
import org.openapitools.client.models.CallClosedCaption
import org.openapitools.client.models.CallCreatedEvent
import org.openapitools.client.models.CallEndedEvent
import org.openapitools.client.models.CallIngressResponse
Expand Down Expand Up @@ -99,6 +102,9 @@ import org.openapitools.client.models.CallTranscriptionFailedEvent
import org.openapitools.client.models.CallTranscriptionStartedEvent
import org.openapitools.client.models.CallTranscriptionStoppedEvent
import org.openapitools.client.models.CallUpdatedEvent
import org.openapitools.client.models.ClosedCaptionEndedEvent
import org.openapitools.client.models.ClosedCaptionEvent
import org.openapitools.client.models.ClosedCaptionStartedEvent
import org.openapitools.client.models.ConnectedEvent
import org.openapitools.client.models.CustomVideoEvent
import org.openapitools.client.models.EgressHLSResponse
Expand All @@ -115,6 +121,7 @@ import org.openapitools.client.models.QueryCallMembersResponse
import org.openapitools.client.models.ReactionResponse
import org.openapitools.client.models.StartHLSBroadcastingResponse
import org.openapitools.client.models.StopLiveResponse
import org.openapitools.client.models.TranscriptionSettingsResponse.ClosedCaptionMode
import org.openapitools.client.models.UnblockedUserEvent
import org.openapitools.client.models.UpdateCallResponse
import org.openapitools.client.models.UpdatedCallPermissionsEvent
Expand Down Expand Up @@ -573,6 +580,37 @@ public class CallState(

internal var acceptedOnThisDevice: Boolean = false

/**
* This [ClosedCaptionManager] is responsible for handling closed captions during the call.
* This includes processing events related to closed captions and maintaining their state.
*/
internal val closedCaptionManager = ClosedCaptionManager()

/**
* Tracks whether closed captioning is currently active for the call.
* True if captioning is ongoing, false otherwise.
*/
public val isCaptioning: StateFlow<Boolean> = closedCaptionManager.closedCaptioning

/**
* Holds the current list of closed captions. This list is updated dynamically
* and contains at most [ClosedCaptionsSettings.maxVisibleCaptions] captions.
*/
public val closedCaptions: StateFlow<List<CallClosedCaption>> = closedCaptionManager.closedCaptions

/**
* Holds the current closed caption mode for the video call. This object contains information about closed
* captioning feature availability. This state is updated dynamically based on the server's transcription
* setting which is [org.openapitools.client.models.TranscriptionSettingsResponse.closedCaptionMode]
*
* Possible values:
* - [ClosedCaptionMode.Available]: Closed captions are available and can be enabled.
* - [ClosedCaptionMode.Disabled]: Closed captions are explicitly disabled.
* - [ClosedCaptionMode.AutoOn]: Closed captions are automatically enabled as soon as user joins the call
* - [ClosedCaptionMode.Unknown]: Represents an unrecognized or unsupported mode.
*/
val ccMode: StateFlow<ClosedCaptionMode> = closedCaptionManager.ccMode

fun handleEvent(event: VideoEvent) {
logger.d { "Updating call state with event ${event::class.java}" }
when (event) {
Expand Down Expand Up @@ -949,6 +987,12 @@ public class CallState(
is CallTranscriptionFailedEvent -> {
_transcribing.value = false
}

is ClosedCaptionStartedEvent,
is ClosedCaptionEvent,
is ClosedCaptionEndedEvent,
->
closedCaptionManager.handleEvent(event)
}
}

Expand Down Expand Up @@ -1244,6 +1288,7 @@ public class CallState(
_team.value = response.team

updateRingingState()
closedCaptionManager.handleCallUpdate(response)
}

fun updateFromResponse(response: GetOrCreateCallResponse) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -114,10 +114,12 @@ import org.openapitools.client.models.SendCallEventRequest
import org.openapitools.client.models.SendCallEventResponse
import org.openapitools.client.models.SendReactionRequest
import org.openapitools.client.models.SendReactionResponse
import org.openapitools.client.models.StartClosedCaptionResponse
import org.openapitools.client.models.StartHLSBroadcastingResponse
import org.openapitools.client.models.StartRecordingRequest
import org.openapitools.client.models.StartTranscriptionRequest
import org.openapitools.client.models.StartTranscriptionResponse
import org.openapitools.client.models.StopClosedCaptionResponse
import org.openapitools.client.models.StopLiveResponse
import org.openapitools.client.models.StopTranscriptionResponse
import org.openapitools.client.models.UnblockUserRequest
Expand Down Expand Up @@ -1115,6 +1117,18 @@ internal class StreamVideoClient internal constructor(
coordinatorConnectionModule.api.listTranscriptions(type, id)
}
}

suspend fun startClosedCaptions(type: String, id: String): Result<StartClosedCaptionResponse> {
return apiCall {
coordinatorConnectionModule.api.startClosedCaptions(type, id)
}
}

suspend fun stopClosedCaptions(type: String, id: String): Result<StopClosedCaptionResponse> {
return apiCall {
coordinatorConnectionModule.api.stopClosedCaptions(type, id)
}
}
}

/** Extension function that makes it easy to use on kotlin, but keeps Java usable as well */
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,226 @@
/*
* Copyright (c) 2014-2024 Stream.io Inc. All rights reserved.
*
* Licensed under the Stream License;
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://github.com/GetStream/stream-video-android/blob/main/LICENSE
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package io.getstream.video.android.core.closedcaptions

import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import org.openapitools.client.models.CallClosedCaption
import org.openapitools.client.models.CallResponse
import org.openapitools.client.models.ClosedCaptionEndedEvent
import org.openapitools.client.models.ClosedCaptionEvent
import org.openapitools.client.models.ClosedCaptionStartedEvent
import org.openapitools.client.models.TranscriptionSettingsResponse.ClosedCaptionMode
import org.openapitools.client.models.VideoEvent

/**
* Manages the lifecycle, state, and configuration of closed captions for a video call.
*
* The [ClosedCaptionManager] is responsible for handling caption updates, maintaining caption states,
* auto-removing and deduplicating captions based on the provided [ClosedCaptionsSettings] and [ClosedCaptionDeduplicationConfig]. It ensures thread-safe
* operations using a [Mutex] and manages jobs for scheduled caption removal using [CoroutineScope].
*
* @property closedCaptionsSettings The configuration that defines how closed captions are managed,
* including auto-dismiss behavior, maximum number of captions to retain, and dismiss time.
*/

class ClosedCaptionManager(
private var closedCaptionsSettings: ClosedCaptionsSettings = ClosedCaptionsSettings(),
private var closedCaptionDeduplicationConfig: ClosedCaptionDeduplicationConfig =
ClosedCaptionDeduplicationConfig(),
) {

/**
* Holds the current list of closed captions. This list is updated dynamically
* and contains at most [ClosedCaptionsSettings.maxVisibleCaptions] captions.
*/

private val _closedCaptions: MutableStateFlow<List<CallClosedCaption>> =
MutableStateFlow(emptyList())
val closedCaptions: StateFlow<List<CallClosedCaption>> = _closedCaptions.asStateFlow()

/**
* A set to track unique keys for deduplication, preventing duplicate captions.
*/
private val seenKeys: MutableSet<String> = mutableSetOf()

/**
* A job to manage the periodic cleanup of outdated or excess keys in seenKeys.
*/
private var seenKeysCleanupJob: Job? = null

/**
* Holds the current closed caption mode for the video call. This object contains information about closed
* captioning feature availability. This state is updated dynamically based on the server's transcription
* setting which is [org.openapitools.client.models.TranscriptionSettingsResponse.closedCaptionMode]
*
* Possible values:
* - [ClosedCaptionMode.Available]: Closed captions are available and can be enabled.
* - [ClosedCaptionMode.Disabled]: Closed captions are explicitly disabled.
* - [ClosedCaptionMode.AutoOn]: Closed captions are automatically enabled as soon as user joins the call
* - [ClosedCaptionMode.Unknown]: Represents an unrecognized or unsupported mode.
*/
private val _ccMode =
MutableStateFlow<ClosedCaptionMode>(ClosedCaptionMode.Disabled)
val ccMode: StateFlow<ClosedCaptionMode> = _ccMode.asStateFlow()

/**
* Tracks whether closed captioning is currently active for the call.
* True if captioning is ongoing, false otherwise.
*/
private val _closedCaptioning: MutableStateFlow<Boolean> = MutableStateFlow(false)
val closedCaptioning: StateFlow<Boolean> = _closedCaptioning

/**
* Manages the job responsible for automatically removing closed captions after a delay.
*/
private var removeCaptionsJob: Job? = null
private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())

/**
* Ensures thread-safe updates to the list of closed captions.
*/
private val mutex = Mutex()

/**
* Updates the current configuration for the closed captions manager.
*
* @param closedCaptionsSettings The new configuration to apply. This affects behavior such as auto-dismiss
* and the number of captions retained.
*/
internal fun updateClosedCaptionsSettings(closedCaptionsSettings: ClosedCaptionsSettings) {
this.closedCaptionsSettings = closedCaptionsSettings
}

/**
* Handles updates from the call response to determine the availability and state
* of closed captions.
*
* @param callResponse The response containing transcription and caption settings for the call.
*/
internal fun handleCallUpdate(callResponse: CallResponse) {
_closedCaptioning.value = callResponse.captioning
_ccMode.value = callResponse.settings.transcription.closedCaptionMode
}

/**
* Processes incoming events related to closed captions, such as new captions being added,
* captioning starting, or captioning ending.
*
* @param videoEvent The event containing closed captioning information.
*/
fun handleEvent(videoEvent: VideoEvent) {
when (videoEvent) {
is ClosedCaptionEvent -> {
addCaption(videoEvent)
_closedCaptioning.value = true
}

is ClosedCaptionStartedEvent -> {
_closedCaptioning.value = true
}

is ClosedCaptionEndedEvent -> {
_closedCaptioning.value = false
}
}
}

/**
* Adds a new caption to the list and manages the auto-dismiss logic.
*
* @param event The event containing the closed caption data to add.
*/
private fun addCaption(event: ClosedCaptionEvent) {
scope.launch {
mutex.withLock {
val uniqueKey = "${event.closedCaption.speakerId}/${event.closedCaption.startTime.toEpochSecond()}"

if (uniqueKey !in seenKeys) {
// Add the caption and keep the latest 2
_closedCaptions.value =
(_closedCaptions.value + event.closedCaption).takeLast(closedCaptionsSettings.maxVisibleCaptions)

seenKeys.add(uniqueKey)
}
}

if (closedCaptionsSettings.autoDismissCaptions) {
removeCaptionsJob?.cancel()
scheduleRemoval()
}

if (closedCaptionDeduplicationConfig.autoRemoveDuplicateCaptions) {
startCleanupTask()
}
}
}

/**
* Schedules the removal of the oldest caption after the specified [ClosedCaptionsSettings.visibilityDurationMs].
*
*/
private fun scheduleRemoval() {
removeCaptionsJob = scope.launch {
delay(closedCaptionsSettings.visibilityDurationMs)
mutex.withLock {
if (_closedCaptions.value.isNotEmpty()) {
_closedCaptions.value =
_closedCaptions.value.drop(1) // Remove the oldest caption
}
}
if (_closedCaptions.value.isNotEmpty()) {
scheduleRemoval() // Continue scheduling removal for remaining captions
}
}
}

/**
* Starts cleanup task to empty [seenKeys] it will run after [ClosedCaptionDeduplicationConfig.duplicateCleanupFrequencyMs]
*/
private fun startCleanupTask() {
if (seenKeysCleanupJob?.isActive == true) return

seenKeysCleanupJob = scope.launch {
while (_closedCaptions.value.isNotEmpty()) {
delay(closedCaptionDeduplicationConfig.duplicateCleanupFrequencyMs)
mutex.withLock {
cleanUpSeenKeys()
}
}
seenKeysCleanupJob?.cancel()
}
}

/**
* Remove the seen keys based on [ClosedCaptionDeduplicationConfig.captionSplitFactor]
*/
private fun cleanUpSeenKeys() {
if (seenKeys.size > 1) {
val itemsToRemove = seenKeys.size / closedCaptionDeduplicationConfig.captionSplitFactor
seenKeys.removeAll(seenKeys.asSequence().take(itemsToRemove).toSet())
}
}
}
Loading
Loading