feat: youtube embed support
Signed-off-by: Infi <infi@infi.sh>
This commit is contained in:
parent
9961809730
commit
001e58bc3c
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 -> {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue