diff --git a/Jetchat/app/build.gradle.kts b/Jetchat/app/build.gradle.kts index 3197802b0b..55c6ffb042 100644 --- a/Jetchat/app/build.gradle.kts +++ b/Jetchat/app/build.gradle.kts @@ -117,7 +117,6 @@ dependencies { implementation(libs.androidx.compose.ui.googlefonts) implementation(libs.androidx.media3.exoplayer) - implementation(libs.androidx.media3.exoplayer.hls) implementation(libs.androidx.media3.ui.compose) debugImplementation(libs.androidx.compose.ui.test.manifest) diff --git a/Jetchat/app/src/main/assets/delightful_tips.mp4 b/Jetchat/app/src/main/assets/delightful_tips.mp4 new file mode 100644 index 0000000000..1186c967e8 Binary files /dev/null and b/Jetchat/app/src/main/assets/delightful_tips.mp4 differ diff --git a/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/Conversation.kt b/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/Conversation.kt index 4d203dca80..6468fca68e 100644 --- a/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/Conversation.kt +++ b/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/Conversation.kt @@ -207,6 +207,7 @@ fun ConversationContent( } var activeVideoUri by rememberSaveable { mutableStateOf(null) } + var activeVideoText by rememberSaveable { mutableStateOf(null) } Box(modifier = modifier.fillMaxSize()) { Scaffold( @@ -287,7 +288,10 @@ fun ConversationContent( modifier = Modifier.weight(1f), scrollState = scrollState, contentPadding = paddingValues, - onVideoClick = { videoUri -> activeVideoUri = videoUri }, + onVideoClick = { videoUri, messageText -> + activeVideoUri = videoUri + activeVideoText = messageText + }, onMessageLikeToggled = onMessageLikeToggled, ) } @@ -301,7 +305,11 @@ fun ConversationContent( activeVideoUri?.let { uri -> FullScreenVideoPlayer( videoUri = uri, - onDismiss = { activeVideoUri = null }, + messageText = activeVideoText, + onDismiss = { + activeVideoUri = null + activeVideoText = null + }, ) } } @@ -435,7 +443,7 @@ fun Messages( scrollState: LazyListState, modifier: Modifier = Modifier, contentPadding: PaddingValues = PaddingValues(0.dp), - onVideoClick: (String) -> Unit = {}, + onVideoClick: (videoUri: String, messageText: String) -> Unit = { _, _ -> }, onMessageLikeToggled: (messageId: String) -> Unit = {}, ) { val scope = rememberCoroutineScope() @@ -518,7 +526,7 @@ fun Message( isUserMe: Boolean, isFirstMessageByAuthor: Boolean, isLastMessageByAuthor: Boolean, - onVideoClick: (String) -> Unit = {}, + onVideoClick: (videoUri: String, messageText: String) -> Unit = { _, _ -> }, onLikeToggled: (messageId: String) -> Unit = {}, ) { val borderColor = if (isUserMe) { @@ -605,7 +613,7 @@ fun AuthorAndTextMessage( isLastMessageByAuthor: Boolean, authorClicked: (String) -> Unit, modifier: Modifier = Modifier, - onVideoClick: (String) -> Unit = {}, + onVideoClick: (videoUri: String, messageText: String) -> Unit = { _, _ -> }, onLikeToggled: (messageId: String) -> Unit = {}, ) { Column( @@ -698,7 +706,7 @@ fun ChatItemBubble( message: Message, isUserMe: Boolean, authorClicked: (String) -> Unit, - onVideoClick: (String) -> Unit = {}, + onVideoClick: (videoUri: String, messageText: String) -> Unit = { _, _ -> }, onLikeToggled: (messageId: String) -> Unit = {}, ) { val isLiked = message.isLiked @@ -847,7 +855,7 @@ fun ChatItemBubble( ) { VideoThumbnail( videoUri = videoUri, - onClick = { onVideoClick(videoUri) }, + onClick = { onVideoClick(videoUri, message.content) }, shape = bubbleShape, modifier = Modifier .padding(if (isLiked) 8.dp else 0.dp) diff --git a/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/SurfaceViewBlur.kt b/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/SurfaceViewBlur.kt index e94d3e2732..450c8ad114 100644 --- a/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/SurfaceViewBlur.kt +++ b/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/SurfaceViewBlur.kt @@ -47,6 +47,7 @@ data class BlurRegionSpec( val boundsInSurface: RectF = RectF(), val boundsInWindow: IntRect = IntRect.Zero, val cornerRadiusPx: Float = 0f, + val cornerRadiiPx: List? = null, val blurRadiusPx: Float = 50f, val alpha: Float = 1.0f, ) @@ -97,7 +98,11 @@ object SurfaceViewBlurHelper { if (spec.boundsInSurface.width() <= 0f || spec.boundsInSurface.height() <= 0f) continue val roundedRectRegion = RoundedRectBlurRegion().apply { bounds = spec.boundsInSurface - setCornerRadii(spec.cornerRadiusPx.coerceAtLeast(0f)) + if (spec.cornerRadiiPx != null && spec.cornerRadiiPx.size == 8) { + setCornerRadii(spec.cornerRadiiPx.toFloatArray()) + } else { + setCornerRadii(spec.cornerRadiusPx.coerceAtLeast(0f)) + } alpha = spec.alpha.coerceIn(0f, 1f) blurRadius = spec.blurRadiusPx.coerceAtLeast(0f) } @@ -152,6 +157,9 @@ fun Modifier.registerBlurRegion( ), ) } + } else if (lastBounds != null) { + lastBounds = null + currentOnRemoveRegion(id) } } } diff --git a/Jetchat/app/src/main/java/com/example/compose/jetchat/data/FakeData.kt b/Jetchat/app/src/main/java/com/example/compose/jetchat/data/FakeData.kt index bfb587d029..c9f2869aca 100644 --- a/Jetchat/app/src/main/java/com/example/compose/jetchat/data/FakeData.kt +++ b/Jetchat/app/src/main/java/com/example/compose/jetchat/data/FakeData.kt @@ -36,7 +36,7 @@ val initialMessages = listOf( "Taylor Brooks", "Here's a demo of the new blur regions feature in action! 🎥", "8:06 PM", - videoUri = "https://test-streams.mux.dev/x36xhzz/x36xhzz.m3u8", + videoUri = "asset:///delightful_tips.mp4", ), Message( "me", diff --git a/Jetchat/app/src/main/java/com/example/compose/jetchat/video/VideoPlayer.kt b/Jetchat/app/src/main/java/com/example/compose/jetchat/video/VideoPlayer.kt index c40df55c2a..c95fc8e021 100644 --- a/Jetchat/app/src/main/java/com/example/compose/jetchat/video/VideoPlayer.kt +++ b/Jetchat/app/src/main/java/com/example/compose/jetchat/video/VideoPlayer.kt @@ -85,8 +85,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.withContext -const val DEFAULT_VIDEO_URL = - "https://test-videos.co.uk/vids/bigbuckbunny/mp4/h264/1080/Big_Buck_Bunny_1080_10s_5MB.mp4" +const val DEFAULT_VIDEO_URL = "asset:///delightful_tips.mp4" /** * Lightweight video thumbnail displayed inside scrollable lists (LazyColumn). @@ -100,20 +99,26 @@ fun VideoThumbnail(videoUri: String, onClick: () -> Unit, modifier: Modifier = M val thumbnailBitmap by produceState(initialValue = null, resolvedUri) { withContext(Dispatchers.IO) { + val retriever = MediaMetadataRetriever() try { - val retriever = MediaMetadataRetriever() val uriString = resolvedUri.toString() - if (uriString.startsWith("http://") || uriString.startsWith("https://")) { - retriever.setDataSource(uriString, HashMap()) + if (uriString.startsWith("asset:///") || uriString.startsWith("file:///android_asset/")) { + val assetPath = uriString + .removePrefix("asset:///") + .removePrefix("file:///android_asset/") + context.assets.openFd(assetPath).use { afd -> + retriever.setDataSource(afd.fileDescriptor, afd.startOffset, afd.length) + } } else { retriever.setDataSource(context, resolvedUri) } val bitmap = retriever.getFrameAtTime(1_000_000, MediaMetadataRetriever.OPTION_CLOSEST_SYNC) ?: retriever.frameAtTime - retriever.release() value = bitmap } catch (e: Throwable) { - // Fallback to stylized dark gradient placeholder on network/codec error + // Fallback to stylized dark gradient placeholder on codec error + } finally { + retriever.release() } } } @@ -181,7 +186,7 @@ fun VideoThumbnail(videoUri: String, onClick: () -> Unit, modifier: Modifier = M */ @OptIn(UnstableApi::class) @Composable -fun FullScreenVideoPlayer(videoUri: String, onDismiss: () -> Unit, modifier: Modifier = Modifier) { +fun FullScreenVideoPlayer(videoUri: String, onDismiss: () -> Unit, modifier: Modifier = Modifier, messageText: String? = null) { val context = LocalContext.current val resolvedUri = remember(videoUri) { resolveVideoUri(videoUri) } @@ -252,10 +257,31 @@ fun FullScreenVideoPlayer(videoUri: String, onDismiss: () -> Unit, modifier: Mod val bottom = boxBottom.coerceIn(0f, surfaceH) if (right > left && bottom > top) { - val cornerRadius = spec.cornerRadiusPx + val r = spec.cornerRadiusPx.coerceAtLeast(0f) + val clipLeft = boxLeft <= 0f + val clipTop = boxTop <= 0f + val clipRight = boxRight >= surfaceW + val clipBottom = boxBottom >= surfaceH + + val topLeft = if (clipLeft || clipTop) 0f else r + val topRight = if (clipRight || clipTop) 0f else r + val bottomLeft = if (clipLeft || clipBottom) 0f else r + val bottomRight = if (clipRight || clipBottom) 0f else r + + val allSame = topLeft == topRight && topRight == bottomLeft && bottomLeft == bottomRight spec.copy( boundsInSurface = RectF(left, top, right, bottom), - cornerRadiusPx = cornerRadius, + cornerRadiusPx = if (allSame) topLeft else 0f, + cornerRadiiPx = if (allSame) { + null + } else { + listOf( + topLeft, topLeft, + topRight, topRight, + bottomLeft, bottomLeft, + bottomRight, bottomRight, + ) + }, ) } else { null @@ -376,6 +402,7 @@ fun FullScreenVideoPlayer(videoUri: String, onDismiss: () -> Unit, modifier: Mod }, onToggleFullscreen = onDismiss, onUpdateBlurRegions = { updateBlurRegions() }, + messageText = messageText, modifier = Modifier.fillMaxSize(), ) } @@ -387,13 +414,20 @@ fun FullScreenVideoPlayer(videoUri: String, onDismiss: () -> Unit, modifier: Mod * Displays a thumbnail in place and launches the fullscreen player screen when tapped. */ @Composable -fun VideoPlayer(videoUri: String, modifier: Modifier = Modifier, autoPlay: Boolean = false, shape: Shape = RoundedCornerShape(16.dp)) { +fun VideoPlayer( + videoUri: String, + modifier: Modifier = Modifier, + autoPlay: Boolean = false, + shape: Shape = RoundedCornerShape(16.dp), + messageText: String? = null, +) { var isPlayerOpen by rememberSaveable { mutableStateOf(autoPlay) } if (isPlayerOpen) { FullScreenVideoPlayer( videoUri = videoUri, onDismiss = { isPlayerOpen = false }, + messageText = messageText, ) } else { VideoThumbnail( diff --git a/Jetchat/app/src/main/java/com/example/compose/jetchat/video/VideoPlayerControls.kt b/Jetchat/app/src/main/java/com/example/compose/jetchat/video/VideoPlayerControls.kt index ebfc0acc33..69cc42f603 100644 --- a/Jetchat/app/src/main/java/com/example/compose/jetchat/video/VideoPlayerControls.kt +++ b/Jetchat/app/src/main/java/com/example/compose/jetchat/video/VideoPlayerControls.kt @@ -23,6 +23,7 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource 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.WindowInsets import androidx.compose.foundation.layout.fillMaxSize @@ -31,9 +32,9 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.safeContent import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme @@ -47,6 +48,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.layout import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.Role @@ -62,6 +64,8 @@ import com.example.compose.jetchat.conversation.BlurRegionSpec import com.example.compose.jetchat.conversation.registerBlurRegion import java.util.Locale +private val MinWidthForInfoCard = 500.dp + /** * Overlay controls for VideoPlayer with frosted glass styling and blur underneath. */ @@ -78,6 +82,7 @@ fun VideoPlayerOverlayControls( onToggleFullscreen: () -> Unit, onUpdateBlurRegions: () -> Unit, modifier: Modifier = Modifier, + messageText: String? = null, ) { Box( modifier = modifier.fillMaxSize(), @@ -116,6 +121,29 @@ fun VideoPlayerOverlayControls( modifier = Modifier.align(Alignment.Center), ) + // Right-docked Video Info Card (only shown when the screen width allows for it) + if (!messageText.isNullOrBlank()) { + VideoPlayerInfoCard( + messageText = messageText, + onUpdateRegion = { spec -> + blurRegionSpecs[spec.id] = spec + onUpdateBlurRegions() + }, + onRemoveRegion = { id -> + if (blurRegionSpecs.remove(id) != null) { + onUpdateBlurRegions() + } + }, + modifier = Modifier + .align(Alignment.CenterEnd) + .then( + if (isFullscreen) + Modifier.windowInsetsPadding(WindowInsets.safeContent) + else Modifier, + ), + ) + } + // Bottom Control Bar Pill VideoPlayerBottomBar( playPauseButtonState = playPauseButtonState, @@ -142,6 +170,53 @@ fun VideoPlayerOverlayControls( } } +@Composable +private fun VideoPlayerInfoCard( + messageText: String, + onUpdateRegion: (BlurRegionSpec) -> Unit, + onRemoveRegion: (String) -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .layout { measurable, constraints -> + if (constraints.maxWidth < MinWidthForInfoCard.roundToPx()) { + onRemoveRegion("video_info_card") + layout(0, 0) {} + } else { + val placeable = measurable.measure(constraints) + layout(placeable.width, placeable.height) { + placeable.placeRelative(0, 0) + } + } + } + .padding(horizontal = 8.dp) + .widthIn(max = 200.dp) + .registerBlurRegion( + id = "video_info_card", + cornerRadius = 16.dp, + onUpdateRegion = onUpdateRegion, + onRemoveRegion = onRemoveRegion, + ) + .background( + color = MaterialTheme.colorScheme.surface.copy(alpha = 0.2f), + shape = MaterialTheme.shapes.large, + ) + .border( + width = 1.dp, + color = MaterialTheme.colorScheme.outline.copy(alpha = 0.2f), + shape = MaterialTheme.shapes.large, + ) + .padding(12.dp), + ) { + Text( + text = messageText, + color = Color.White, + style = MaterialTheme.typography.bodyMedium, + ) + } +} + @Composable private fun VideoPlayerExitFullscreenButton( onClick: () -> Unit, @@ -162,12 +237,12 @@ private fun VideoPlayerExitFullscreenButton( onRemoveRegion = onRemoveRegion, ) .background( - color = Color(0x33000000), + color = MaterialTheme.colorScheme.surface.copy(alpha = 0.2f), shape = CircleShape, ) .border( width = 1.dp, - color = Color(0x33FFFFFF), + color = MaterialTheme.colorScheme.outline.copy(alpha = 0.2f), shape = CircleShape, ), ) { @@ -204,12 +279,12 @@ private fun VideoPlayerCenterPlayButton( ) .clip(CircleShape) .background( - color = Color(0x33000000), + color = MaterialTheme.colorScheme.surface.copy(alpha = 0.2f), shape = CircleShape, ) .border( width = 1.dp, - color = Color(0x4DFFFFFF), + color = MaterialTheme.colorScheme.outline.copy(alpha = 0.3f), shape = CircleShape, ) .clickable( @@ -236,8 +311,6 @@ private fun VideoPlayerCenterPlayButton( } } -private val BottomBarShape = RoundedCornerShape(16.dp) - @OptIn(UnstableApi::class) @Composable private fun VideoPlayerBottomBar( @@ -268,18 +341,18 @@ private fun VideoPlayerBottomBar( .padding(horizontal = 8.dp, vertical = 16.dp) .registerBlurRegion( id = "bottom_bar", - cornerRadius = 0.dp, + cornerRadius = 16.dp, onUpdateRegion = onUpdateRegion, onRemoveRegion = onRemoveRegion, ) .background( - color = Color(0x33000000), - shape = BottomBarShape, + color = MaterialTheme.colorScheme.surface.copy(alpha = 0.2f), + shape = MaterialTheme.shapes.large, ) .border( width = 1.dp, - color = Color(0x33FFFFFF), - shape = BottomBarShape, + color = MaterialTheme.colorScheme.outline.copy(alpha = 0.2f), + shape = MaterialTheme.shapes.large, ) .padding(horizontal = 8.dp, vertical = 4.dp), ) { diff --git a/Jetchat/gradle/libs.versions.toml b/Jetchat/gradle/libs.versions.toml index 15e05936ef..5cd7681fac 100644 --- a/Jetchat/gradle/libs.versions.toml +++ b/Jetchat/gradle/libs.versions.toml @@ -46,7 +46,6 @@ ksp = "2.3.12" maps-compose = "8.6.0" # @keep media3Exoplayer = "1.11.1" -media3ExoplayerHls = "1.11.1" media3UiCompose = "1.11.1" minSdk = "24" okhttp = "5.5.0" @@ -107,7 +106,6 @@ androidx-lifecycle-viewmodel-ktx = { module = "androidx.lifecycle:lifecycle-view androidx-lifecycle-viewmodel-savedstate = { module = "androidx.lifecycle:lifecycle-viewmodel-savedstate", version.ref = "androidx-lifecycle-compose" } androidx-material-icons-core = { module = "androidx.compose.material:material-icons-core" } androidx-media3-exoplayer = { module = "androidx.media3:media3-exoplayer", version.ref = "media3Exoplayer" } -androidx-media3-exoplayer-hls = { module = "androidx.media3:media3-exoplayer-hls", version.ref = "media3ExoplayerHls" } androidx-media3-ui-compose = { module = "androidx.media3:media3-ui-compose", version.ref = "media3UiCompose" } androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "androidx-navigation" } androidx-navigation-fragment = { module = "androidx.navigation:navigation-fragment-ktx", version.ref = "androidx-navigation" }