feat: youtube embed support

Signed-off-by: Infi <infi@infi.sh>
This commit is contained in:
Infi 2024-10-03 19:31:56 +02:00
parent 9961809730
commit 001e58bc3c
6 changed files with 202 additions and 23 deletions

View File

@ -82,7 +82,15 @@ data class Image(
@Serializable
data class Special(
val type: String? = null
val type: String? = null,
val id: String? = null,
val timestamp: String? = null,
@SerialName("content_type")
val contentType: String? = null,
@SerialName("album_id")
val albumID: String? = null,
@SerialName("track_id")
val trackID: String? = null,
)
@Serializable

View File

@ -9,10 +9,10 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.wrapContentWidth
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.surfaceColorAtElevation
@ -28,6 +28,7 @@ import chat.revolt.api.internals.BrushCompat
import chat.revolt.api.internals.solidColor
import chat.revolt.api.routes.microservices.january.asJanuaryProxyUrl
import chat.revolt.api.schemas.Embed
import chat.revolt.components.chat.specialembeds.SpecialEmbedSwitch
import chat.revolt.components.generic.RemoteImage
import chat.revolt.components.markdown.RichMarkdown
import chat.revolt.api.schemas.Embed as EmbedSchema
@ -42,7 +43,7 @@ fun RegularEmbed(
modifier = modifier
.clip(MaterialTheme.shapes.medium)
.background(MaterialTheme.colorScheme.surfaceColorAtElevation(2.dp))
.fillMaxWidth()
.wrapContentWidth(Alignment.Start)
.height(IntrinsicSize.Min)
) {
// Stripe at the left side of the embed
@ -58,7 +59,6 @@ fun RegularEmbed(
Box(
modifier = Modifier
.fillMaxWidth()
.padding(8.dp)
) {
Column {
@ -74,8 +74,7 @@ fun RegularEmbed(
} else {
Modifier
}
)
.fillMaxWidth(),
),
verticalAlignment = Alignment.CenterVertically
) {
embed.iconURL?.let {
@ -133,6 +132,11 @@ fun RegularEmbed(
description = null // decorative
)
}
// Special
embed.special?.let {
SpecialEmbedSwitch(it)
}
}
}
}

View File

@ -12,10 +12,10 @@ import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material3.LocalContentColor
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material3.Icon
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.surfaceColorAtElevation
@ -90,6 +90,25 @@ fun ImageAttachment(attachment: AutumnResource) {
}
}
@Composable
fun VideoPlayButton() {
Box(
modifier = Modifier
.width(48.dp)
.aspectRatio(1f)
.clip(MaterialTheme.shapes.medium)
.background(MaterialTheme.colorScheme.surfaceColorAtElevation(2.dp))
)
Icon(
imageVector = Icons.Default.PlayArrow,
contentDescription = stringResource(id = R.string.media_viewer_play),
modifier = Modifier
.width(32.dp)
.aspectRatio(1f)
)
}
@Composable
fun VideoAttachment(attachment: AutumnResource) {
val url = "$REVOLT_FILES/attachments/${attachment.id}/${attachment.filename}"
@ -114,22 +133,8 @@ fun VideoAttachment(attachment: AutumnResource) {
),
description = attachment.filename ?: "Video"
)
Box(
modifier = Modifier
.width(48.dp)
.aspectRatio(1f)
.clip(MaterialTheme.shapes.medium)
.background(MaterialTheme.colorScheme.surfaceColorAtElevation(2.dp))
)
Icon(
imageVector = Icons.Default.PlayArrow,
contentDescription = stringResource(id = R.string.media_viewer_play),
modifier = Modifier
.width(32.dp)
.aspectRatio(1f)
)
VideoPlayButton()
}
}
}

View File

@ -0,0 +1,13 @@
package chat.revolt.components.chat.specialembeds
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import chat.revolt.api.schemas.Special
@Composable
fun SpecialEmbedSwitch(special: Special, modifier: Modifier = Modifier) {
when (special.type) {
"YouTube" -> YoutubeEmbedSwitch(special, modifier)
else -> {}
}
}

View File

@ -0,0 +1,147 @@
package chat.revolt.components.chat.specialembeds
import android.annotation.SuppressLint
import android.webkit.WebView
import androidx.compose.animation.Crossfade
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.material3.MaterialTheme
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.draw.clip
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.viewinterop.AndroidView
import chat.revolt.R
import chat.revolt.api.schemas.Special
import chat.revolt.components.chat.VideoPlayButton
import chat.revolt.components.generic.RemoteImage
import org.intellij.lang.annotations.Language
@Language("HTML")
private const val YOUTUBE_EMBED_TEMPLATE = """
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
body {
margin: 0;
padding: 0;
overflow: hidden;
height: 100vh;
width: 100vw;
background-color: black;
}
</style>
</head>
<body>
<script>
const ytParams = new URLSearchParams()
ytParams.set("autoplay", "1")
ytParams.set("fs", "0")
if ({{useTimestamp}}) {
ytParams.set("start", "{{timestamp}}")
}
const frame = document.createElement("iframe")
frame.setAttribute("src", `https://www.youtube.com/embed/{{videoId}}?${'$'}{ytParams.toString()}`)
frame.setAttribute("width", window.innerWidth)
frame.setAttribute("height", window.innerHeight)
frame.setAttribute("frameborder", 0)
frame.setAttribute("allow", "accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share")
frame.setAttribute("referrerpolicy", "no-referrer")
frame.setAttribute("allowfullscreen", "allowfullscreen")
frame.setAttribute("title", "YouTube video player")
document.body.appendChild(frame)
</script>
</body>
"""
@SuppressLint("SetJavaScriptEnabled")
@Composable
fun YouTubeEmbed(special: Special, modifier: Modifier = Modifier) {
if (special.id == null) return
AndroidView(
factory = { ctx ->
WebView(ctx).apply {
settings.apply {
javaScriptEnabled = true
mediaPlaybackRequiresUserGesture = false
javaScriptCanOpenWindowsAutomatically = true
builtInZoomControls = false
loadWithOverviewMode = true
useWideViewPort = true
displayZoomControls = false
setSupportZoom(false)
isVerticalScrollBarEnabled = false
isHorizontalScrollBarEnabled = false
}
}
},
update = {
it.loadDataWithBaseURL(
null,
YOUTUBE_EMBED_TEMPLATE
.replace("{{videoId}}", special.id)
.replace("{{useTimestamp}}", (special.timestamp != null).toString())
.replace("{{timestamp}}", special.timestamp ?: ""),
"text/html",
"UTF-8",
null
)
},
modifier = modifier
.clip(MaterialTheme.shapes.medium)
.width(with(LocalDensity.current) { 1280.toDp() })
.aspectRatio(16f / 9f)
)
}
/**
* A switch that displays the thumbnail of a YouTube video and switches to the embedded player when clicked.
* This ensures that the video is only loaded when the user wants to watch it for bandwidth reasons.
*/
@Composable
fun YoutubeEmbedSwitch(special: Special, modifier: Modifier = Modifier) {
var embedEnabled by remember { mutableStateOf(false) }
Crossfade(targetState = embedEnabled, label = "embed enabled") {
if (it) {
YouTubeEmbed(special, modifier)
} else {
Box(
contentAlignment = Alignment.Center,
modifier = modifier
.clip(MaterialTheme.shapes.medium)
.clickable {
embedEnabled = true
}
.width(with(LocalDensity.current) { 1280.toDp() })
.aspectRatio(16f / 9f)
.wrapContentHeight()
.background(MaterialTheme.colorScheme.surface)
) {
RemoteImage(
"https://i3.ytimg.com/vi/${special.id ?: "none"}/maxresdefault.jpg",
stringResource(R.string.message_embed_special_youtube_switch_alt),
width = 1280,
height = 720,
)
VideoPlayButton()
}
}
}
}

View File

@ -197,6 +197,8 @@
<string name="message_blocked">Blocked message</string>
<string name="message_failed_to_send">Failed to send, long press for options</string>
<string name="message_embed_special_youtube_switch_alt">Tap to play video from YouTube</string>
<string name="system_message_ownership_changed_alt">Ownership changed</string>
<string name="system_message_channel_icon_changed_alt">Channel icon changed</string>
<string name="system_message_channel_description_changed_alt">Channel description changed</string>