diff --git a/demo-app/src/main/kotlin/io/getstream/video/android/ui/call/CallScreen.kt b/demo-app/src/main/kotlin/io/getstream/video/android/ui/call/CallScreen.kt index 52ac27132a6..98568aa6d6c 100644 --- a/demo-app/src/main/kotlin/io/getstream/video/android/ui/call/CallScreen.kt +++ b/demo-app/src/main/kotlin/io/getstream/video/android/ui/call/CallScreen.kt @@ -77,6 +77,7 @@ import io.getstream.video.android.compose.ui.components.base.StreamIconToggleBut import io.getstream.video.android.compose.ui.components.call.CallAppBar import io.getstream.video.android.compose.ui.components.call.activecall.CallContent import io.getstream.video.android.compose.ui.components.call.controls.actions.ChatDialogAction +import io.getstream.video.android.compose.ui.components.call.controls.actions.ClosedCaptionsToggleAction import io.getstream.video.android.compose.ui.components.call.controls.actions.DefaultOnCallActionHandler import io.getstream.video.android.compose.ui.components.call.controls.actions.FlipCameraAction import io.getstream.video.android.compose.ui.components.call.controls.actions.GenericAction @@ -102,6 +103,10 @@ import io.getstream.video.android.mock.StreamPreviewDataUtils import io.getstream.video.android.mock.previewCall import io.getstream.video.android.tooling.extensions.toPx import io.getstream.video.android.tooling.util.StreamFlavors +import io.getstream.video.android.ui.closedcaptions.ClosedCaptionUiState +import io.getstream.video.android.ui.closedcaptions.ClosedCaptionUiState.Available.toClosedCaptionUiState +import io.getstream.video.android.ui.closedcaptions.ClosedCaptionsContainer +import io.getstream.video.android.ui.closedcaptions.ClosedCaptionsDefaults import io.getstream.video.android.ui.menu.SettingsMenu import io.getstream.video.android.ui.menu.VideoFilter import io.getstream.video.android.ui.menu.availableVideoFilters @@ -176,6 +181,43 @@ fun CallScreen( PaddingValues(0.dp) } + /** + * Logic to Closed Captions UI State and render UI accordingly + */ + + val ccMode by call.state.ccMode.collectAsStateWithLifecycle() + val captioning by call.state.isCaptioning.collectAsStateWithLifecycle() + + var closedCaptionUiState: ClosedCaptionUiState by remember { + mutableStateOf(ccMode.toClosedCaptionUiState()) + } + + val updateClosedCaptionUiState: (ClosedCaptionUiState) -> Unit = { newState -> + closedCaptionUiState = newState + } + + val onLocalClosedCaptionsClick: () -> Unit = { + scope.launch { + when (closedCaptionUiState) { + is ClosedCaptionUiState.Running -> { + updateClosedCaptionUiState(ClosedCaptionUiState.Available) + } + is ClosedCaptionUiState.Available -> { + if (captioning) { + updateClosedCaptionUiState(ClosedCaptionUiState.Running) + } else { + call.startClosedCaptions() + } + } + else -> { + throw Exception( + "This state $closedCaptionUiState should not invoke any ui operation", + ) + } + } + } + } + VideoTheme { ChatDialog( state = chatState, @@ -261,6 +303,13 @@ fun CallScreen( }, ) Spacer(modifier = Modifier.size(VideoTheme.dimens.spacingM)) + ClosedCaptionsToggleAction( + active = closedCaptionUiState == ClosedCaptionUiState.Running, + onCallAction = { + onLocalClosedCaptionsClick.invoke() + }, + ) + Spacer(modifier = Modifier.size(VideoTheme.dimens.spacingM)) if (call.hasCapability(OwnCapability.StartRecordCall) || call.hasCapability( OwnCapability.StopRecordCall, ) @@ -379,6 +428,23 @@ fun CallScreen( } } }, + closedCaptionUi = { call -> + if (orientation == Configuration.ORIENTATION_LANDSCAPE) { + ClosedCaptionsContainer( + call, + ClosedCaptionsDefaults.streamThemeConfig(), + closedCaptionUiState, + ) + } else { + ClosedCaptionsContainer( + call, + ClosedCaptionsDefaults.streamThemeConfig().copy( + yOffset = (-80).dp, + ), + closedCaptionUiState, + ) + } + }, ) if (orientation == Configuration.ORIENTATION_LANDSCAPE) { StreamIconToggleButton( @@ -531,6 +597,8 @@ fun CallScreen( isShowingStats = true isShowingSettingMenu = false }, + closedCaptionUiState = closedCaptionUiState, + onClosedCaptionsToggle = onLocalClosedCaptionsClick, ) } diff --git a/demo-app/src/main/kotlin/io/getstream/video/android/ui/closedcaptions/ClosedCaptionUi.kt b/demo-app/src/main/kotlin/io/getstream/video/android/ui/closedcaptions/ClosedCaptionUi.kt new file mode 100644 index 00000000000..13fd2669ba2 --- /dev/null +++ b/demo-app/src/main/kotlin/io/getstream/video/android/ui/closedcaptions/ClosedCaptionUi.kt @@ -0,0 +1,210 @@ +/* + * 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.ui.closedcaptions + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.wrapContentWidth +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalInspectionMode +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import io.getstream.video.android.core.Call +import org.openapitools.client.models.CallClosedCaption + +/** + * A set of composables and supporting classes for displaying and customizing closed captions in a call. + * + * This collection includes a demo preview, the main container for closed captions, + * and UI elements for rendering individual captions and caption lists. + */ + +/** + * A preview function for displaying a demo of the closed captions list. + * + * Demonstrates how the [ClosedCaptionList] renders multiple captions with default configurations. + * Useful for testing and visualizing the closed captions UI in isolation. + */ +@Preview +@Composable +public fun ClosedCaptionListDemo() { + val config = ClosedCaptionsDefaults.streamThemeConfig() + ClosedCaptionList( + arrayListOf( + ClosedCaptionUiModel("Rahul", "This is closed captions text in Call Content"), + ClosedCaptionUiModel("Princy", "Hi I am Princy"), + ClosedCaptionUiModel("Meenu", "Hi I am Meenu, I am from Noida. I am a physiotherapist"), + ), + config, + ) +} + +/** + * A composable container for rendering closed captions in a call. + * + * This container adapts its behavior based on the environment: + * - In `LocalInspectionMode`, it displays a static demo of closed captions using [ClosedCaptionListDemo]. + * - During a live call, it listens to the state of the [Call]'s [ClosedCaptionManager] to render + * dynamically updated captions. + * + * @param call The current [Call] instance, providing state and caption data. + * @param config A [ClosedCaptionsThemeConfig] defining the styling and positioning of the container. + */ +@Composable +public fun ClosedCaptionsContainer( + call: Call, + config: ClosedCaptionsThemeConfig = ClosedCaptionsDefaults.config, + closedCaptionUiState: ClosedCaptionUiState, +) { + if (LocalInspectionMode.current) { + Box( + modifier = Modifier + .fillMaxSize() + .offset(y = config.yOffset) + .padding(horizontal = config.horizontalMargin), + + contentAlignment = Alignment.BottomCenter, + ) { + ClosedCaptionListDemo() + } + } else { + val closedCaptions by call.state.closedCaptions + .collectAsStateWithLifecycle() + + if (closedCaptionUiState == ClosedCaptionUiState.Running && closedCaptions.isNotEmpty()) { + Box( + modifier = Modifier + .fillMaxSize() + .offset(y = config.yOffset) + .padding(horizontal = config.horizontalMargin), + + contentAlignment = Alignment.BottomCenter, + ) { + ClosedCaptionList(closedCaptions.map { it.toClosedCaptionUiModel(call) }, config) + } + } + } +} + +/** + * A composable function for displaying a list of closed captions. + * + * This function uses a [LazyColumn] to display captions with a background, padding, + * and styling defined in the provided [config]. It limits the number of visible captions + * to [ClosedCaptionsThemeConfig.maxVisibleCaptions]. + * + * @param captions The list of [ClosedCaptionUiModel]s to display. + * @param config A [ClosedCaptionsThemeConfig] defining the layout and styling of the caption list. + */ + +@Composable +public fun ClosedCaptionList( + captions: List, + config: ClosedCaptionsThemeConfig, +) { + LazyColumn( + modifier = Modifier + .background( + color = Color.Black.copy(alpha = config.boxAlpha), + shape = RoundedCornerShape(16.dp), + ) + .fillMaxWidth() + .padding(config.boxPadding), + userScrollEnabled = false, + horizontalAlignment = Alignment.Start, + ) { + itemsIndexed(captions.takeLast(config.maxVisibleCaptions)) { index, item -> + ClosedCaptionUi(item, index != captions.size - 1, config) + } + } +} + +/** + * A composable function for rendering an individual closed caption. + * + * Displays the speaker's name and their caption text, with optional semi-transparency for + * earlier captions (controlled by [semiFade]). + * + * @param closedCaptionUiModel The [ClosedCaptionUiModel] containing the speaker and text. + * @param semiFade Whether to render the caption with reduced opacity. + * @param config A [ClosedCaptionsThemeConfig] defining the text colors and styling. + */ + +@Composable +public fun ClosedCaptionUi( + closedCaptionUiModel: ClosedCaptionUiModel, + semiFade: Boolean, + config: ClosedCaptionsThemeConfig, +) { + val alpha = if (semiFade) 0.6f else 1f + + val formattedSpeakerText = closedCaptionUiModel.speaker + ":" + + Row( + modifier = Modifier.alpha(alpha), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text(formattedSpeakerText, color = config.speakerColor) + Text( + closedCaptionUiModel.text, + color = config.textColor, + modifier = Modifier.wrapContentWidth(), + ) + } +} + +/** + * Represents a single closed caption with the speaker's name and their text. + * + * @property speaker The name of the speaker for this caption. + * @property text The text of the caption. + */ +public data class ClosedCaptionUiModel(val speaker: String, val text: String) + +/** + * Converts a [CallClosedCaption] into a [ClosedCaptionUiModel] for UI rendering. + * + * Maps the speaker's ID to their name using the participants in the given [Call]. + * If the speaker cannot be identified, the speaker is labeled as "N/A". + * + * @param call The [Call] instance containing the list of participants. + * @return A [ClosedCaptionUiModel] containing the speaker's name and caption text. + */ +public fun CallClosedCaption.toClosedCaptionUiModel(call: Call): ClosedCaptionUiModel { + val participants = call.state.participants.value + val user = participants.firstOrNull { it.userId.value == this.speakerId } + return ClosedCaptionUiModel( + speaker = user?.userNameOrId?.value ?: "N/A", + text = this.text, + ) +} diff --git a/demo-app/src/main/kotlin/io/getstream/video/android/ui/closedcaptions/ClosedCaptionUiState.kt b/demo-app/src/main/kotlin/io/getstream/video/android/ui/closedcaptions/ClosedCaptionUiState.kt new file mode 100644 index 00000000000..df800f53760 --- /dev/null +++ b/demo-app/src/main/kotlin/io/getstream/video/android/ui/closedcaptions/ClosedCaptionUiState.kt @@ -0,0 +1,49 @@ +/* + * 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.ui.closedcaptions + +import org.openapitools.client.models.TranscriptionSettingsResponse + +sealed class ClosedCaptionUiState { + /** + * Indicates that closed captions are available for the current call but are not actively running/displaying. + * This state usually occurs when the captioning feature is supported but not yet activated/displayed. + */ + data object Available : ClosedCaptionUiState() + + /** + * Indicates that closed captions are actively running and displaying captions during the call. + */ + data object Running : ClosedCaptionUiState() + + /** + * Indicates that closed captions are unavailable for the current call. + * This state is used when the feature is disabled or not supported. + */ + data object UnAvailable : ClosedCaptionUiState() + + public fun TranscriptionSettingsResponse.ClosedCaptionMode.toClosedCaptionUiState(): ClosedCaptionUiState { + return when (this) { + is TranscriptionSettingsResponse.ClosedCaptionMode.Available, + is TranscriptionSettingsResponse.ClosedCaptionMode.AutoOn, + -> + Available + else -> + UnAvailable + } + } +} diff --git a/demo-app/src/main/kotlin/io/getstream/video/android/ui/closedcaptions/ClosedCaptionsTheme.kt b/demo-app/src/main/kotlin/io/getstream/video/android/ui/closedcaptions/ClosedCaptionsTheme.kt new file mode 100644 index 00000000000..3aed2a525b5 --- /dev/null +++ b/demo-app/src/main/kotlin/io/getstream/video/android/ui/closedcaptions/ClosedCaptionsTheme.kt @@ -0,0 +1,113 @@ +/* + * 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.ui.closedcaptions + +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import io.getstream.video.android.compose.theme.StreamColors + +/** + * Provides default configurations for the Closed Captions UI. + * + * The [ClosedCaptionsDefaults] object contains a predefined instance of [ClosedCaptionsThemeConfig], + * which serves as the default styling and behavior configuration for the closed captions UI. + * Developers can use this default configuration or provide a custom one to override specific values. + */ + +public object ClosedCaptionsDefaults { + /** + * The default configuration for closed captions, defining layout, styling, and behavior. + * + * - `yOffset`: Vertical offset for positioning the closed captions container. + * - `horizontalMargin`: Horizontal margin around the container. + * - `boxAlpha`: Opacity of the background box containing the captions. + * - `boxPadding`: Padding inside the background box. + * - `speakerColor`: Color used for the speaker's name text. + * - `textColor`: Color used for the caption text. + * - `maxVisibleCaptions`: The maximum number of captions to display in the container at once. + * - `roundedCornerShape`: The corner radius of the background box. + */ + public val config: ClosedCaptionsThemeConfig = ClosedCaptionsThemeConfig( + yOffset = -50.dp, + horizontalMargin = 16.dp, + boxAlpha = 0.5f, + boxPadding = 12.dp, + speakerColor = Color.Yellow, + textColor = Color.White, + maxVisibleCaptions = 3, + roundedCornerShape = RoundedCornerShape(16.dp), + ) + + @Composable + public fun streamThemeConfig(): ClosedCaptionsThemeConfig { + val colors = StreamColors.defaultColors() + return config.copy( + backgroundColor = colors.baseSheetPrimary, + speakerColor = colors.baseQuaternary, + textColor = colors.basePrimary, + ) + } +} + +/** + * Defines the configuration for Closed Captions UI, allowing customization of its layout, styling, and behavior. + * + * This configuration can be used to style the closed captions container and its contents. Developers can + * customize the appearance by overriding specific values as needed. + * + * @param yOffset Vertical offset for the closed captions container. Negative values move the container upwards. + * @param horizontalMargin Horizontal margin around the container. + * @param boxAlpha Background opacity of the closed captions container, where `0.0f` is fully transparent + * and `1.0f` is fully opaque. + * @param boxPadding Padding inside the background box of the closed captions container. + * @param backgroundColor Color used for rendering the background box of the closed captions container. + * @param speakerColor Color used for rendering the speaker's name text. + * @param textColor Color used for rendering the caption text. + * @param maxVisibleCaptions The maximum number of captions visible at one time in the closed captions container. + * Must be less than or equal to [ClosedCaptionsConfig.maxCaptions] to ensure consistency. + * @param roundedCornerShape A shape used for the caption container. + * + * Example Usage: + * ``` + * val customConfig = ClosedCaptionsThemeConfig( + * yOffset = -100.dp, + * horizontalMargin = 20.dp, + * boxAlpha = 0.7f, + * boxPadding = 16.dp, + * backgroundColor = Color.Black, + * speakerColor = Color.Cyan, + * textColor = Color.Green, + * maxVisibleCaptions = 5, + * roundedCornerShape = RounderCornerShape(12.dp), + * ) + * ``` + */ +public data class ClosedCaptionsThemeConfig( + val yOffset: Dp = -50.dp, + val horizontalMargin: Dp = 0.dp, + val boxAlpha: Float = 1f, + val boxPadding: Dp = 0.dp, + val backgroundColor: Color = Color.Black, + val speakerColor: Color = Color.Yellow, + val textColor: Color = Color.White, + val maxVisibleCaptions: Int = 3, + val roundedCornerShape: Shape? = null, +) diff --git a/demo-app/src/main/kotlin/io/getstream/video/android/ui/menu/MenuDefinitions.kt b/demo-app/src/main/kotlin/io/getstream/video/android/ui/menu/MenuDefinitions.kt index 9aa22b04731..a00fc1f572e 100644 --- a/demo-app/src/main/kotlin/io/getstream/video/android/ui/menu/MenuDefinitions.kt +++ b/demo-app/src/main/kotlin/io/getstream/video/android/ui/menu/MenuDefinitions.kt @@ -26,6 +26,9 @@ import androidx.compose.material.icons.filled.Audiotrack import androidx.compose.material.icons.filled.AutoGraph import androidx.compose.material.icons.filled.Balance import androidx.compose.material.icons.filled.BluetoothAudio +import androidx.compose.material.icons.filled.ClosedCaption +import androidx.compose.material.icons.filled.ClosedCaptionDisabled +import androidx.compose.material.icons.filled.ClosedCaptionOff import androidx.compose.material.icons.filled.Crop import androidx.compose.material.icons.filled.CropFree import androidx.compose.material.icons.filled.Feedback @@ -46,6 +49,7 @@ import androidx.compose.material.icons.filled.VideocamOff import io.getstream.video.android.compose.ui.components.video.VideoScalingType import io.getstream.video.android.core.audio.StreamAudioDevice import io.getstream.video.android.core.model.PreferredVideoResolution +import io.getstream.video.android.ui.closedcaptions.ClosedCaptionUiState import io.getstream.video.android.ui.menu.base.ActionMenuItem import io.getstream.video.android.ui.menu.base.DynamicSubMenuItem import io.getstream.video.android.ui.menu.base.MenuItem @@ -79,6 +83,8 @@ fun defaultStreamMenu( onSelectScaleType: (VideoScalingType) -> Unit, availableDevices: List, loadRecordings: suspend () -> List, + onToggleClosedCaptions: () -> Unit = {}, + closedCaptionUiState: ClosedCaptionUiState, ) = buildList { add( DynamicSubMenuItem( @@ -196,6 +202,7 @@ fun defaultStreamMenu( ), ), ) + add(getCCActionMenu(closedCaptionUiState, onToggleClosedCaptions)) if (showDebugOptions) { add( SubMenuItem( @@ -217,6 +224,37 @@ fun defaultStreamMenu( } } +fun getCCActionMenu( + closedCaptionUiState: ClosedCaptionUiState, + onToggleClosedCaptions: () -> Unit, +): ActionMenuItem { + return when (closedCaptionUiState) { + is ClosedCaptionUiState.Available -> { + ActionMenuItem( + title = "Start Closed Caption", + icon = Icons.Default.ClosedCaptionOff, + action = onToggleClosedCaptions, + ) + } + + is ClosedCaptionUiState.Running -> { + ActionMenuItem( + title = "Stop Closed Caption", + icon = Icons.Default.ClosedCaption, + action = onToggleClosedCaptions, + ) + } + + is ClosedCaptionUiState.UnAvailable -> { + ActionMenuItem( + title = "Closed Caption are unavailable", + icon = Icons.Default.ClosedCaptionDisabled, + action = { }, + ) + } + } +} + /** * Lists the available codecs for this device as list of [MenuItem] */ diff --git a/demo-app/src/main/kotlin/io/getstream/video/android/ui/menu/SettingsMenu.kt b/demo-app/src/main/kotlin/io/getstream/video/android/ui/menu/SettingsMenu.kt index 53d4582c37a..6ad3c99373d 100644 --- a/demo-app/src/main/kotlin/io/getstream/video/android/ui/menu/SettingsMenu.kt +++ b/demo-app/src/main/kotlin/io/getstream/video/android/ui/menu/SettingsMenu.kt @@ -57,6 +57,7 @@ import io.getstream.video.android.core.mapper.ReactionMapper import io.getstream.video.android.core.model.PreferredVideoResolution import io.getstream.video.android.tooling.extensions.toPx import io.getstream.video.android.ui.call.ReactionsMenu +import io.getstream.video.android.ui.closedcaptions.ClosedCaptionUiState import io.getstream.video.android.ui.menu.base.ActionMenuItem import io.getstream.video.android.ui.menu.base.DynamicMenu import io.getstream.video.android.ui.menu.base.MenuItem @@ -82,6 +83,8 @@ internal fun SettingsMenu( onToggleIncomingVideoVisibility: (Boolean) -> Unit, onShowCallStats: () -> Unit, onSelectScaleType: (VideoScalingType) -> Unit, + closedCaptionUiState: ClosedCaptionUiState, + onClosedCaptionsToggle: () -> Unit, ) { val context = LocalContext.current val scope = rememberCoroutineScope() @@ -250,6 +253,8 @@ internal fun SettingsMenu( isScreenShareEnabled = isScreenSharing, onSelectScaleType = onSelectScaleType, loadRecordings = onLoadRecordings, + onToggleClosedCaptions = onClosedCaptionsToggle, + closedCaptionUiState = closedCaptionUiState, ), ) } @@ -317,6 +322,8 @@ private fun SettingsMenuPreview() { isIncomingVideoEnabled = true, onToggleIncomingVideoEnabled = {}, loadRecordings = { emptyList() }, + onToggleClosedCaptions = { }, + closedCaptionUiState = ClosedCaptionUiState.Available, ), ) } diff --git a/demo-app/src/main/kotlin/io/getstream/video/android/ui/menu/base/DynamicMenu.kt b/demo-app/src/main/kotlin/io/getstream/video/android/ui/menu/base/DynamicMenu.kt index 5037a728de3..76bb9b73633 100644 --- a/demo-app/src/main/kotlin/io/getstream/video/android/ui/menu/base/DynamicMenu.kt +++ b/demo-app/src/main/kotlin/io/getstream/video/android/ui/menu/base/DynamicMenu.kt @@ -46,6 +46,7 @@ import androidx.compose.ui.unit.dp import io.getstream.video.android.compose.theme.VideoTheme import io.getstream.video.android.compose.ui.components.base.StreamToggleButton import io.getstream.video.android.compose.ui.components.base.styling.StyleSize +import io.getstream.video.android.ui.closedcaptions.ClosedCaptionUiState import io.getstream.video.android.ui.menu.debugSubmenu import io.getstream.video.android.ui.menu.defaultStreamMenu import io.getstream.video.android.ui.menu.reconnectMenu @@ -231,6 +232,8 @@ private fun DynamicMenuPreview() { onToggleIncomingVideoEnabled = {}, onSelectScaleType = {}, loadRecordings = { emptyList() }, + onToggleClosedCaptions = {}, + closedCaptionUiState = ClosedCaptionUiState.Available, ), ) } @@ -264,6 +267,8 @@ private fun DynamicMenuDebugOptionPreview() { isIncomingVideoEnabled = true, onToggleIncomingVideoEnabled = {}, loadRecordings = { emptyList() }, + onToggleClosedCaptions = {}, + closedCaptionUiState = ClosedCaptionUiState.Available, ), ) } diff --git a/stream-video-android-core/api/stream-video-android-core.api b/stream-video-android-core/api/stream-video-android-core.api index dea846ba8ac..ebee6359f80 100644 --- a/stream-video-android-core/api/stream-video-android-core.api +++ b/stream-video-android-core/api/stream-video-android-core.api @@ -1114,6 +1114,17 @@ public final class io/getstream/video/android/core/call/state/ChooseLayout : io/ public fun toString ()Ljava/lang/String; } +public final class io/getstream/video/android/core/call/state/ClosedCaptionsAction : io/getstream/video/android/core/call/state/CallAction { + public fun (Z)V + public final fun component1 ()Z + public final fun copy (Z)Lio/getstream/video/android/core/call/state/ClosedCaptionsAction; + public static synthetic fun copy$default (Lio/getstream/video/android/core/call/state/ClosedCaptionsAction;ZILjava/lang/Object;)Lio/getstream/video/android/core/call/state/ClosedCaptionsAction; + public fun equals (Ljava/lang/Object;)Z + public fun hashCode ()I + public final fun isEnabled ()Z + public fun toString ()Ljava/lang/String; +} + public class io/getstream/video/android/core/call/state/CustomAction : io/getstream/video/android/core/call/state/CallAction { public fun (Ljava/util/Map;Ljava/lang/String;)V public synthetic fun (Ljava/util/Map;Ljava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V @@ -2918,6 +2929,23 @@ public final class io/getstream/video/android/core/closedcaptions/ClosedCaptionM public final fun handleEvent (Lorg/openapitools/client/models/VideoEvent;)V } +public final class io/getstream/video/android/core/closedcaptions/ClosedCaptionsConfig { + public fun ()V + public fun (JZI)V + public synthetic fun (JZIILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()J + public final fun component2 ()Z + public final fun component3 ()I + public final fun copy (JZI)Lio/getstream/video/android/core/closedcaptions/ClosedCaptionsConfig; + public static synthetic fun copy$default (Lio/getstream/video/android/core/closedcaptions/ClosedCaptionsConfig;JZIILjava/lang/Object;)Lio/getstream/video/android/core/closedcaptions/ClosedCaptionsConfig; + public fun equals (Ljava/lang/Object;)Z + public final fun getAutoDismissCaptions ()Z + public final fun getCaptionsAutoDismissTime ()J + public final fun getMaxCaptions ()I + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + public final class io/getstream/video/android/core/closedcaptions/ClosedCaptionsSettings { public fun ()V public fun (JZI)V diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/state/CallAction.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/state/CallAction.kt index d965070c5d5..d5f33c8063b 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/state/CallAction.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/state/CallAction.kt @@ -89,6 +89,10 @@ public data class Settings( val isEnabled: Boolean, ) : CallAction +public data class ClosedCaptionsAction( + val isEnabled: Boolean, +) : CallAction + /** * Action to show a reaction popup. */ diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/closedcaptions/ClosedCaptionManager.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/closedcaptions/ClosedCaptionManager.kt index d44bae30c7d..ff8e4f2a383 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/closedcaptions/ClosedCaptionManager.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/closedcaptions/ClosedCaptionManager.kt @@ -35,6 +35,10 @@ import org.openapitools.client.models.ClosedCaptionStartedEvent import org.openapitools.client.models.TranscriptionSettingsResponse.ClosedCaptionMode import org.openapitools.client.models.VideoEvent +private fun ClosedCaptionEvent.key(): String { + return "${closedCaption.speakerId}/${closedCaption.startTime.toEpochSecond()}" +} + /** * Manages the lifecycle, state, and configuration of closed captions for a video call. * @@ -156,8 +160,7 @@ class ClosedCaptionManager( private fun addCaption(event: ClosedCaptionEvent) { scope.launch { mutex.withLock { - val uniqueKey = "${event.closedCaption.speakerId}/${event.closedCaption.startTime.toEpochSecond()}" - + val uniqueKey = event.key() if (uniqueKey !in seenKeys) { // Add the caption and keep the latest 2 _closedCaptions.value = diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/closedcaptions/ClosedCaptionsConfig.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/closedcaptions/ClosedCaptionsConfig.kt new file mode 100644 index 00000000000..bc9e08f8ea9 --- /dev/null +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/closedcaptions/ClosedCaptionsConfig.kt @@ -0,0 +1,40 @@ +/* + * 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 + +private const val DEFAULT_CAPTIONS_AUTO_DISMISS_TIME_MS = 6000L + +/** + * Configuration for managing closed captions in the [ClosedCaptionManager]. + * + * @param captionsAutoDismissTime The duration (in milliseconds) after which captions will be automatically removed. + * Set to [DEFAULT_CAPTIONS_AUTO_DISMISS_TIME_MS] by default. + * + * @param autoDismissCaptions Determines whether closed captions should be automatically dismissed after a delay. + * If set to `false`, captions will remain visible indefinitely. + * + * @param maxCaptions The maximum number of closed captions to retain in the [ClosedCaptionManager.closedCaptions] flow. + * Must be greater than or equal to [io.getstream.video.android.compose.ui.components.closedcaptions.ClosedCaptionsThemeConfig.maxVisibleCaptions] + * to ensure the UI has sufficient data to render. + * + */ + +data class ClosedCaptionsConfig( + val captionsAutoDismissTime: Long = DEFAULT_CAPTIONS_AUTO_DISMISS_TIME_MS, + val autoDismissCaptions: Boolean = true, + val maxCaptions: Int = 3, // Default to keep the latest 3 captions +) diff --git a/stream-video-android-ui-compose/api/stream-video-android-ui-compose.api b/stream-video-android-ui-compose/api/stream-video-android-ui-compose.api index 21f130adb82..4707d2bdbb8 100644 --- a/stream-video-android-ui-compose/api/stream-video-android-ui-compose.api +++ b/stream-video-android-ui-compose/api/stream-video-android-ui-compose.api @@ -1101,6 +1101,11 @@ public final class io/getstream/video/android/compose/ui/components/call/control public static final fun ChatDialogAction-_8q-z2c (Landroidx/compose/ui/Modifier;ZLjava/lang/Integer;Landroidx/compose/ui/graphics/vector/ImageVector;Landroidx/compose/ui/graphics/Color;Landroidx/compose/ui/graphics/Color;Landroidx/compose/ui/graphics/Color;Lkotlin/jvm/functions/Function1;Landroidx/compose/runtime/Composer;II)V } +public final class io/getstream/video/android/compose/ui/components/call/controls/actions/ClosedCaptionsToggleActionKt { + public static final fun ClosedCaptionsToggleAction-PBJxc4c (Landroidx/compose/ui/Modifier;ZZLandroidx/compose/ui/graphics/Shape;Landroidx/compose/ui/graphics/Color;Landroidx/compose/ui/graphics/Color;Lkotlin/jvm/functions/Function1;Landroidx/compose/runtime/Composer;II)V + public static final fun ClosedCaptionsToggleActionPreview (Landroidx/compose/runtime/Composer;I)V +} + public final class io/getstream/video/android/compose/ui/components/call/controls/actions/ComposableSingletons$AcceptCallActionKt { public static final field INSTANCE Lio/getstream/video/android/compose/ui/components/call/controls/actions/ComposableSingletons$AcceptCallActionKt; public static field lambda-1 Lkotlin/jvm/functions/Function2; @@ -1122,6 +1127,13 @@ public final class io/getstream/video/android/compose/ui/components/call/control public final fun getLambda-1$stream_video_android_ui_compose_release ()Lkotlin/jvm/functions/Function2; } +public final class io/getstream/video/android/compose/ui/components/call/controls/actions/ComposableSingletons$ClosedCaptionsToggleActionKt { + public static final field INSTANCE Lio/getstream/video/android/compose/ui/components/call/controls/actions/ComposableSingletons$ClosedCaptionsToggleActionKt; + public static field lambda-1 Lkotlin/jvm/functions/Function2; + public fun ()V + public final fun getLambda-1$stream_video_android_ui_compose_release ()Lkotlin/jvm/functions/Function2; +} + public final class io/getstream/video/android/compose/ui/components/call/controls/actions/ComposableSingletons$DeclineCallActionKt { public static final field INSTANCE Lio/getstream/video/android/compose/ui/components/call/controls/actions/ComposableSingletons$DeclineCallActionKt; public static field lambda-1 Lkotlin/jvm/functions/Function2; diff --git a/stream-video-android-ui-compose/src/main/kotlin/io/getstream/video/android/compose/ui/components/call/activecall/CallContent.kt b/stream-video-android-ui-compose/src/main/kotlin/io/getstream/video/android/compose/ui/components/call/activecall/CallContent.kt index 4ba97abea20..d2c237045a9 100644 --- a/stream-video-android-ui-compose/src/main/kotlin/io/getstream/video/android/compose/ui/components/call/activecall/CallContent.kt +++ b/stream-video-android-ui-compose/src/main/kotlin/io/getstream/video/android/compose/ui/components/call/activecall/CallContent.kt @@ -233,7 +233,6 @@ public fun CallContent( showDiagnostics = false } } - closedCaptionUi(call) }, ) diff --git a/stream-video-android-ui-compose/src/main/kotlin/io/getstream/video/android/compose/ui/components/call/controls/actions/ClosedCaptionsToggleAction.kt b/stream-video-android-ui-compose/src/main/kotlin/io/getstream/video/android/compose/ui/components/call/controls/actions/ClosedCaptionsToggleAction.kt new file mode 100644 index 00000000000..28c909a1611 --- /dev/null +++ b/stream-video-android-ui-compose/src/main/kotlin/io/getstream/video/android/compose/ui/components/call/controls/actions/ClosedCaptionsToggleAction.kt @@ -0,0 +1,76 @@ +/* + * 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.compose.ui.components.call.controls.actions + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ClosedCaption +import androidx.compose.material.icons.filled.ClosedCaptionOff +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.tooling.preview.Preview +import io.getstream.video.android.compose.theme.VideoTheme +import io.getstream.video.android.core.call.state.CallAction +import io.getstream.video.android.core.call.state.ClosedCaptionsAction + +/** + * A call action button represents displaying the call captions. + * + * @param modifier Optional Modifier for this action button. + * @param enabled Whether or not this action button will handle input events. + * @param onCallAction A [CallAction] event that will be fired. + */ +@Composable +public fun ClosedCaptionsToggleAction( + modifier: Modifier = Modifier, + active: Boolean, + enabled: Boolean = true, + shape: Shape? = null, + enabledColor: Color? = null, + disabledColor: Color? = null, + onCallAction: (ClosedCaptionsAction) -> Unit, +): Unit = ToggleAction( + isActionActive = active, + iconOnOff = + Pair(Icons.Default.ClosedCaption, Icons.Default.ClosedCaptionOff), + modifier = modifier, + enabled = enabled, shape = shape, + enabledColor = enabledColor, disabledColor = disabledColor, + offStyle = VideoTheme.styles.buttonStyles.primaryIconButtonStyle(), + onStyle = VideoTheme.styles.buttonStyles.secondaryIconButtonStyle(), +) { + onCallAction(ClosedCaptionsAction(!active)) +} + +@Preview +@Composable +public fun ClosedCaptionsToggleActionPreview() { + VideoTheme { + Column { + Row { + ClosedCaptionsToggleAction(active = false) { + } + + ClosedCaptionsToggleAction(active = true) { + } + } + } + } +}