Skip to content

Commit df1b6cf

Browse files
committed
Closed Captions Demo-app implementation
1 parent 6994458 commit df1b6cf

File tree

8 files changed

+475
-1
lines changed

8 files changed

+475
-1
lines changed

demo-app/src/main/kotlin/io/getstream/video/android/ui/call/CallScreen.kt

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,11 @@ import io.getstream.video.android.mock.StreamPreviewDataUtils
102102
import io.getstream.video.android.mock.previewCall
103103
import io.getstream.video.android.tooling.extensions.toPx
104104
import io.getstream.video.android.tooling.util.StreamFlavors
105+
import io.getstream.video.android.ui.closedcaptions.ClosedCaptionUiState
106+
import io.getstream.video.android.ui.closedcaptions.ClosedCaptionUiState.Available.toClosedCaptionUiState
107+
import io.getstream.video.android.ui.closedcaptions.ClosedCaptionsContainer
108+
import io.getstream.video.android.ui.closedcaptions.ClosedCaptionsDefaults
109+
import io.getstream.video.android.ui.closedcaptions.ClosedCaptionsThemeConfig
105110
import io.getstream.video.android.ui.menu.SettingsMenu
106111
import io.getstream.video.android.ui.menu.VideoFilter
107112
import io.getstream.video.android.ui.menu.availableVideoFilters
@@ -176,6 +181,43 @@ fun CallScreen(
176181
PaddingValues(0.dp)
177182
}
178183

184+
/**
185+
* Logic to Closed Captions UI State and render UI accordingly
186+
*/
187+
188+
val ccMode by call.state.closedCaptionManager.ccMode.collectAsStateWithLifecycle()
189+
val captioning by call.state.closedCaptionManager.closedCaptioning.collectAsStateWithLifecycle()
190+
191+
var closedCaptionUiState: ClosedCaptionUiState by remember {
192+
mutableStateOf(ccMode.toClosedCaptionUiState())
193+
}
194+
195+
val updateClosedCaptionUiState: (ClosedCaptionUiState) -> Unit = { newState ->
196+
closedCaptionUiState = newState
197+
}
198+
199+
val onLocalClosedCaptionsClick: () -> Unit = {
200+
scope.launch {
201+
when (closedCaptionUiState) {
202+
is ClosedCaptionUiState.Running -> {
203+
updateClosedCaptionUiState(ClosedCaptionUiState.Available)
204+
}
205+
is ClosedCaptionUiState.Available -> {
206+
if (captioning) {
207+
updateClosedCaptionUiState(ClosedCaptionUiState.Running)
208+
} else {
209+
call.startClosedCaptions()
210+
}
211+
}
212+
else -> {
213+
throw Exception(
214+
"This state $closedCaptionUiState should not invoke any ui operation",
215+
)
216+
}
217+
}
218+
}
219+
}
220+
179221
VideoTheme {
180222
ChatDialog(
181223
state = chatState,
@@ -379,6 +421,21 @@ fun CallScreen(
379421
}
380422
}
381423
},
424+
closedCaptionUi = { call ->
425+
if (orientation == Configuration.ORIENTATION_LANDSCAPE) {
426+
ClosedCaptionsContainer(
427+
call,
428+
ClosedCaptionsDefaults.config,
429+
closedCaptionUiState,
430+
)
431+
} else {
432+
ClosedCaptionsContainer(
433+
call,
434+
ClosedCaptionsThemeConfig(yOffset = -80.dp),
435+
closedCaptionUiState,
436+
)
437+
}
438+
},
382439
)
383440
if (orientation == Configuration.ORIENTATION_LANDSCAPE) {
384441
StreamIconToggleButton(
@@ -531,6 +588,8 @@ fun CallScreen(
531588
isShowingStats = true
532589
isShowingSettingMenu = false
533590
},
591+
closedCaptionUiState = closedCaptionUiState,
592+
onClosedCaptionsToggle = onLocalClosedCaptionsClick,
534593
)
535594
}
536595

Lines changed: 210 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,210 @@
1+
/*
2+
* Copyright (c) 2014-2024 Stream.io Inc. All rights reserved.
3+
*
4+
* Licensed under the Stream License;
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://github.com/GetStream/stream-video-android/blob/main/LICENSE
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package io.getstream.video.android.ui.closedcaptions
18+
19+
import androidx.compose.foundation.background
20+
import androidx.compose.foundation.layout.Arrangement
21+
import androidx.compose.foundation.layout.Box
22+
import androidx.compose.foundation.layout.Row
23+
import androidx.compose.foundation.layout.fillMaxSize
24+
import androidx.compose.foundation.layout.fillMaxWidth
25+
import androidx.compose.foundation.layout.offset
26+
import androidx.compose.foundation.layout.padding
27+
import androidx.compose.foundation.layout.wrapContentWidth
28+
import androidx.compose.foundation.lazy.LazyColumn
29+
import androidx.compose.foundation.lazy.itemsIndexed
30+
import androidx.compose.foundation.shape.RoundedCornerShape
31+
import androidx.compose.material.Text
32+
import androidx.compose.runtime.Composable
33+
import androidx.compose.runtime.getValue
34+
import androidx.compose.ui.Alignment
35+
import androidx.compose.ui.Modifier
36+
import androidx.compose.ui.draw.alpha
37+
import androidx.compose.ui.graphics.Color
38+
import androidx.compose.ui.platform.LocalInspectionMode
39+
import androidx.compose.ui.tooling.preview.Preview
40+
import androidx.compose.ui.unit.dp
41+
import androidx.lifecycle.compose.collectAsStateWithLifecycle
42+
import io.getstream.video.android.core.Call
43+
import org.openapitools.client.models.CallClosedCaption
44+
45+
/**
46+
* A set of composables and supporting classes for displaying and customizing closed captions in a call.
47+
*
48+
* This collection includes a demo preview, the main container for closed captions,
49+
* and UI elements for rendering individual captions and caption lists.
50+
*/
51+
52+
/**
53+
* A preview function for displaying a demo of the closed captions list.
54+
*
55+
* Demonstrates how the [ClosedCaptionList] renders multiple captions with default configurations.
56+
* Useful for testing and visualizing the closed captions UI in isolation.
57+
*/
58+
@Preview
59+
@Composable
60+
public fun ClosedCaptionListDemo() {
61+
val config = ClosedCaptionsDefaults.config
62+
ClosedCaptionList(
63+
arrayListOf(
64+
ClosedCaptionUiModel("Rahul", "This is closed captions text in Call Content"),
65+
ClosedCaptionUiModel("Princy", "Hi I am Princy"),
66+
ClosedCaptionUiModel("Meenu", "Hi I am Meenu, I am from Noida. I am a physiotherapist"),
67+
),
68+
config,
69+
)
70+
}
71+
72+
/**
73+
* A composable container for rendering closed captions in a call.
74+
*
75+
* This container adapts its behavior based on the environment:
76+
* - In `LocalInspectionMode`, it displays a static demo of closed captions using [ClosedCaptionListDemo].
77+
* - During a live call, it listens to the state of the [Call]'s [ClosedCaptionManager] to render
78+
* dynamically updated captions.
79+
*
80+
* @param call The current [Call] instance, providing state and caption data.
81+
* @param config A [ClosedCaptionsThemeConfig] defining the styling and positioning of the container.
82+
*/
83+
@Composable
84+
public fun ClosedCaptionsContainer(
85+
call: Call,
86+
config: ClosedCaptionsThemeConfig = ClosedCaptionsDefaults.config,
87+
closedCaptionUiState: ClosedCaptionUiState,
88+
) {
89+
if (LocalInspectionMode.current) {
90+
Box(
91+
modifier = Modifier
92+
.fillMaxSize()
93+
.offset(y = config.yOffset)
94+
.padding(horizontal = config.horizontalMargin),
95+
96+
contentAlignment = Alignment.BottomCenter,
97+
) {
98+
ClosedCaptionListDemo()
99+
}
100+
} else {
101+
val closedCaptions by call.state.closedCaptionManager.closedCaptions
102+
.collectAsStateWithLifecycle()
103+
104+
if (closedCaptionUiState == ClosedCaptionUiState.Running && closedCaptions.isNotEmpty()) {
105+
Box(
106+
modifier = Modifier
107+
.fillMaxSize()
108+
.offset(y = config.yOffset)
109+
.padding(horizontal = config.horizontalMargin),
110+
111+
contentAlignment = Alignment.BottomCenter,
112+
) {
113+
ClosedCaptionList(closedCaptions.map { it.toClosedCaptionUiModel(call) }, config)
114+
}
115+
}
116+
}
117+
}
118+
119+
/**
120+
* A composable function for displaying a list of closed captions.
121+
*
122+
* This function uses a [LazyColumn] to display captions with a background, padding,
123+
* and styling defined in the provided [config]. It limits the number of visible captions
124+
* to [ClosedCaptionsThemeConfig.maxVisibleCaptions].
125+
*
126+
* @param captions The list of [ClosedCaptionUiModel]s to display.
127+
* @param config A [ClosedCaptionsThemeConfig] defining the layout and styling of the caption list.
128+
*/
129+
130+
@Composable
131+
public fun ClosedCaptionList(
132+
captions: List<ClosedCaptionUiModel>,
133+
config: ClosedCaptionsThemeConfig,
134+
) {
135+
LazyColumn(
136+
modifier = Modifier
137+
.background(
138+
color = Color.Black.copy(alpha = config.boxAlpha),
139+
shape = RoundedCornerShape(16.dp),
140+
)
141+
.fillMaxWidth()
142+
.padding(config.boxPadding),
143+
userScrollEnabled = false,
144+
horizontalAlignment = Alignment.CenterHorizontally,
145+
) {
146+
itemsIndexed(captions.takeLast(config.maxVisibleCaptions)) { index, item ->
147+
ClosedCaptionUi(item, index != captions.size - 1, config)
148+
}
149+
}
150+
}
151+
152+
/**
153+
* A composable function for rendering an individual closed caption.
154+
*
155+
* Displays the speaker's name and their caption text, with optional semi-transparency for
156+
* earlier captions (controlled by [semiFade]).
157+
*
158+
* @param closedCaptionUiModel The [ClosedCaptionUiModel] containing the speaker and text.
159+
* @param semiFade Whether to render the caption with reduced opacity.
160+
* @param config A [ClosedCaptionsThemeConfig] defining the text colors and styling.
161+
*/
162+
163+
@Composable
164+
public fun ClosedCaptionUi(
165+
closedCaptionUiModel: ClosedCaptionUiModel,
166+
semiFade: Boolean,
167+
config: ClosedCaptionsThemeConfig,
168+
) {
169+
val alpha = if (semiFade) 0.6f else 1f
170+
171+
val formattedSpeakerText = closedCaptionUiModel.speaker + ":"
172+
173+
Row(
174+
modifier = Modifier.alpha(alpha),
175+
horizontalArrangement = Arrangement.spacedBy(8.dp),
176+
) {
177+
Text(formattedSpeakerText, color = config.speakerColor)
178+
Text(
179+
closedCaptionUiModel.text,
180+
color = config.textColor,
181+
modifier = Modifier.wrapContentWidth(),
182+
)
183+
}
184+
}
185+
186+
/**
187+
* Represents a single closed caption with the speaker's name and their text.
188+
*
189+
* @property speaker The name of the speaker for this caption.
190+
* @property text The text of the caption.
191+
*/
192+
public data class ClosedCaptionUiModel(val speaker: String, val text: String)
193+
194+
/**
195+
* Converts a [CallClosedCaption] into a [ClosedCaptionUiModel] for UI rendering.
196+
*
197+
* Maps the speaker's ID to their name using the participants in the given [Call].
198+
* If the speaker cannot be identified, the speaker is labeled as "N/A".
199+
*
200+
* @param call The [Call] instance containing the list of participants.
201+
* @return A [ClosedCaptionUiModel] containing the speaker's name and caption text.
202+
*/
203+
public fun CallClosedCaption.toClosedCaptionUiModel(call: Call): ClosedCaptionUiModel {
204+
val participants = call.state.participants.value
205+
val user = participants.firstOrNull { it.userId.value == this.speakerId }
206+
return ClosedCaptionUiModel(
207+
speaker = user?.userNameOrId?.value ?: "N/A",
208+
text = this.text,
209+
)
210+
}
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
/*
2+
* Copyright (c) 2014-2024 Stream.io Inc. All rights reserved.
3+
*
4+
* Licensed under the Stream License;
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://github.com/GetStream/stream-video-android/blob/main/LICENSE
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package io.getstream.video.android.ui.closedcaptions
18+
19+
import org.openapitools.client.models.TranscriptionSettingsResponse
20+
21+
sealed class ClosedCaptionUiState {
22+
/**
23+
* Indicates that closed captions are available for the current call but are not actively running/displaying.
24+
* This state usually occurs when the captioning feature is supported but not yet activated/displayed.
25+
*/
26+
data object Available : ClosedCaptionUiState()
27+
28+
/**
29+
* Indicates that closed captions are actively running and displaying captions during the call.
30+
*/
31+
data object Running : ClosedCaptionUiState()
32+
33+
/**
34+
* Indicates that closed captions are unavailable for the current call.
35+
* This state is used when the feature is disabled or not supported.
36+
*/
37+
data object UnAvailable : ClosedCaptionUiState()
38+
39+
public fun TranscriptionSettingsResponse.ClosedCaptionMode.toClosedCaptionUiState(): ClosedCaptionUiState {
40+
return when (this) {
41+
is TranscriptionSettingsResponse.ClosedCaptionMode.Available,
42+
is TranscriptionSettingsResponse.ClosedCaptionMode.AutoOn,
43+
->
44+
Available
45+
else ->
46+
UnAvailable
47+
}
48+
}
49+
}

0 commit comments

Comments
 (0)