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 @Serializable
data class Special( 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 @Serializable

View File

@ -9,10 +9,10 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.wrapContentWidth
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.surfaceColorAtElevation 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.internals.solidColor
import chat.revolt.api.routes.microservices.january.asJanuaryProxyUrl import chat.revolt.api.routes.microservices.january.asJanuaryProxyUrl
import chat.revolt.api.schemas.Embed import chat.revolt.api.schemas.Embed
import chat.revolt.components.chat.specialembeds.SpecialEmbedSwitch
import chat.revolt.components.generic.RemoteImage import chat.revolt.components.generic.RemoteImage
import chat.revolt.components.markdown.RichMarkdown import chat.revolt.components.markdown.RichMarkdown
import chat.revolt.api.schemas.Embed as EmbedSchema import chat.revolt.api.schemas.Embed as EmbedSchema
@ -42,7 +43,7 @@ fun RegularEmbed(
modifier = modifier modifier = modifier
.clip(MaterialTheme.shapes.medium) .clip(MaterialTheme.shapes.medium)
.background(MaterialTheme.colorScheme.surfaceColorAtElevation(2.dp)) .background(MaterialTheme.colorScheme.surfaceColorAtElevation(2.dp))
.fillMaxWidth() .wrapContentWidth(Alignment.Start)
.height(IntrinsicSize.Min) .height(IntrinsicSize.Min)
) { ) {
// Stripe at the left side of the embed // Stripe at the left side of the embed
@ -58,7 +59,6 @@ fun RegularEmbed(
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxWidth()
.padding(8.dp) .padding(8.dp)
) { ) {
Column { Column {
@ -74,8 +74,7 @@ fun RegularEmbed(
} else { } else {
Modifier Modifier
} }
) ),
.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
embed.iconURL?.let { embed.iconURL?.let {
@ -133,6 +132,11 @@ fun RegularEmbed(
description = null // decorative 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.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.material3.LocalContentColor
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.surfaceColorAtElevation 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 @Composable
fun VideoAttachment(attachment: AutumnResource) { fun VideoAttachment(attachment: AutumnResource) {
val url = "$REVOLT_FILES/attachments/${attachment.id}/${attachment.filename}" val url = "$REVOLT_FILES/attachments/${attachment.id}/${attachment.filename}"
@ -114,22 +133,8 @@ fun VideoAttachment(attachment: AutumnResource) {
), ),
description = attachment.filename ?: "Video" description = attachment.filename ?: "Video"
) )
Box( VideoPlayButton()
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)
)
} }
} }
} }

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_blocked">Blocked message</string>
<string name="message_failed_to_send">Failed to send, long press for options</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_ownership_changed_alt">Ownership changed</string>
<string name="system_message_channel_icon_changed_alt">Channel icon 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> <string name="system_message_channel_description_changed_alt">Channel description changed</string>