Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay into wip-async

This commit is contained in:
Kelvin 2025-07-03 19:05:38 +02:00
commit 542a7f212d
15 changed files with 650 additions and 31 deletions

View file

@ -0,0 +1,38 @@
package com.futo.platformplayer
import android.graphics.Color
import org.junit.Assert.assertEquals
import org.junit.Test
import toAndroidColor
class CSSColorTests {
@Test
fun test1() {
val androidHex = "#80336699"
val androidColorInt = Color.parseColor(androidHex)
val cssHex = "#33669980"
val cssColor = CSSColor.parseColor(cssHex)
assertEquals(
"CSSColor($cssHex).toAndroidColor() should equal Color.parseColor($androidHex)",
androidColorInt,
cssColor.toAndroidColor(),
)
}
@Test
fun test2() {
val androidHex = "#123ABC"
val androidColorInt = Color.parseColor(androidHex)
val cssHex = "#123ABCFF"
val cssColor = CSSColor.parseColor(cssHex)
assertEquals(
"CSSColor($cssHex).toAndroidColor() should equal Color.parseColor($androidHex)",
androidColorInt,
cssColor.toAndroidColor()
)
}
}

View file

@ -707,11 +707,12 @@ class LiveEventViewCount extends LiveEvent {
} }
} }
class LiveEventRaid extends LiveEvent { class LiveEventRaid extends LiveEvent {
constructor(targetUrl, targetName, targetThumbnail) { constructor(targetUrl, targetName, targetThumbnail, isOutgoing) {
super(100); super(100);
this.targetUrl = targetUrl; this.targetUrl = targetUrl;
this.targetName = targetName; this.targetName = targetName;
this.targetThumbnail = targetThumbnail; this.targetThumbnail = targetThumbnail;
this.isOutgoing = isOutgoing ?? true;
} }
} }

View file

@ -0,0 +1,319 @@
import kotlin.math.*
class CSSColor(r: Float, g: Float, b: Float, a: Float = 1f) {
init {
require(r in 0f..1f && g in 0f..1f && b in 0f..1f && a in 0f..1f) {
"RGBA channels must be in [0,1]"
}
}
// -- RGB(A) channels stored 01 --
var r: Float = r.coerceIn(0f, 1f)
set(v) { field = v.coerceIn(0f, 1f); _hslDirty = true }
var g: Float = g.coerceIn(0f, 1f)
set(v) { field = v.coerceIn(0f, 1f); _hslDirty = true }
var b: Float = b.coerceIn(0f, 1f)
set(v) { field = v.coerceIn(0f, 1f); _hslDirty = true }
var a: Float = a.coerceIn(0f, 1f)
set(v) { field = v.coerceIn(0f, 1f) }
// -- Int views of RGBA 0255 --
var red: Int
get() = (r * 255).roundToInt()
set(v) { r = (v.coerceIn(0, 255) / 255f) }
var green: Int
get() = (g * 255).roundToInt()
set(v) { g = (v.coerceIn(0, 255) / 255f) }
var blue: Int
get() = (b * 255).roundToInt()
set(v) { b = (v.coerceIn(0, 255) / 255f) }
var alpha: Int
get() = (a * 255).roundToInt()
set(v) { a = (v.coerceIn(0, 255) / 255f) }
// -- HSLA storage & lazy recompute flags --
private var _h: Float = 0f
private var _s: Float = 0f
private var _l: Float = 0f
private var _hslDirty = true
/** Hue [0...360) */
var hue: Float
get() { computeHslIfNeeded(); return _h }
set(v) { setHsl(v, saturation, lightness) }
/** Saturation [0...1] */
var saturation: Float
get() { computeHslIfNeeded(); return _s }
set(v) { setHsl(hue, v, lightness) }
/** Lightness [0...1] */
var lightness: Float
get() { computeHslIfNeeded(); return _l }
set(v) { setHsl(hue, saturation, v) }
private fun computeHslIfNeeded() {
if (!_hslDirty) return
val max = max(max(r, g), b)
val min = min(min(r, g), b)
val d = max - min
_l = (max + min) / 2f
_s = if (d == 0f) 0f else d / (1f - abs(2f * _l - 1f))
_h = when {
d == 0f -> 0f
max == r -> ((g - b) / d % 6f) * 60f
max == g -> (((b - r) / d) + 2f) * 60f
else -> (((r - g) / d) + 4f) * 60f
}.let { if (it < 0f) it + 360f else it }
_hslDirty = false
}
/**
* Set all three HSL channels at once.
* Hue in degrees [0...360), s/l [0...1].
*/
fun setHsl(h: Float, s: Float, l: Float) {
val hh = ((h % 360f) + 360f) % 360f
val cc = (1f - abs(2f * l - 1f)) * s
val x = cc * (1f - abs((hh / 60f) % 2f - 1f))
val m = l - cc / 2f
val (rp, gp, bp) = when {
hh < 60f -> Triple(cc, x, 0f)
hh < 120f -> Triple(x, cc, 0f)
hh < 180f -> Triple(0f, cc, x)
hh < 240f -> Triple(0f, x, cc)
hh < 300f -> Triple(x, 0f, cc)
else -> Triple(cc, 0f, x)
}
r = rp + m; g = gp + m; b = bp + m
_h = hh; _s = s; _l = l; _hslDirty = false
}
/** Return 0xRRGGBBAA int */
fun toRgbaInt(): Int {
val ai = (a * 255).roundToInt() and 0xFF
val ri = (r * 255).roundToInt() and 0xFF
val gi = (g * 255).roundToInt() and 0xFF
val bi = (b * 255).roundToInt() and 0xFF
return (ri shl 24) or (gi shl 16) or (bi shl 8) or ai
}
/** Return 0xAARRGGBB int */
fun toArgbInt(): Int {
val ai = (a * 255).roundToInt() and 0xFF
val ri = (r * 255).roundToInt() and 0xFF
val gi = (g * 255).roundToInt() and 0xFF
val bi = (b * 255).roundToInt() and 0xFF
return (ai shl 24) or (ri shl 16) or (gi shl 8) or bi
}
// — Convenience modifiers (chainable) —
/** Lighten by fraction [0...1] */
fun lighten(fraction: Float): CSSColor = apply {
lightness = (lightness + fraction).coerceIn(0f, 1f)
}
/** Darken by fraction [0...1] */
fun darken(fraction: Float): CSSColor = apply {
lightness = (lightness - fraction).coerceIn(0f, 1f)
}
/** Increase saturation by fraction [0...1] */
fun saturate(fraction: Float): CSSColor = apply {
saturation = (saturation + fraction).coerceIn(0f, 1f)
}
/** Decrease saturation by fraction [0...1] */
fun desaturate(fraction: Float): CSSColor = apply {
saturation = (saturation - fraction).coerceIn(0f, 1f)
}
/** Rotate hue by degrees (can be negative) */
fun rotateHue(degrees: Float): CSSColor = apply {
hue = (hue + degrees) % 360f
}
companion object {
/** Create from Android 0xAARRGGBB */
@JvmStatic fun fromArgb(color: Int): CSSColor {
val a = ((color ushr 24) and 0xFF) / 255f
val r = ((color ushr 16) and 0xFF) / 255f
val g = ((color ushr 8) and 0xFF) / 255f
val b = ( color and 0xFF) / 255f
return CSSColor(r, g, b, a)
}
/** Create from Android 0xRRGGBBAA */
@JvmStatic fun fromRgba(color: Int): CSSColor {
val r = ((color ushr 24) and 0xFF) / 255f
val g = ((color ushr 16) and 0xFF) / 255f
val b = ((color ushr 8) and 0xFF) / 255f
val a = ( color and 0xFF) / 255f
return CSSColor(r, g, b, a)
}
@JvmStatic fun fromAndroidColor(color: Int): CSSColor {
return fromArgb(color)
}
private val NAMED_HEX = mapOf(
"aliceblue" to "F0F8FF", "antiquewhite" to "FAEBD7", "aqua" to "00FFFF",
"aquamarine" to "7FFFD4", "azure" to "F0FFFF", "beige" to "F5F5DC",
"bisque" to "FFE4C4", "black" to "000000", "blanchedalmond" to "FFEBCD",
"blue" to "0000FF", "blueviolet" to "8A2BE2", "brown" to "A52A2A",
"burlywood" to "DEB887", "cadetblue" to "5F9EA0", "chartreuse" to "7FFF00",
"chocolate" to "D2691E", "coral" to "FF7F50", "cornflowerblue" to "6495ED",
"cornsilk" to "FFF8DC", "crimson" to "DC143C", "cyan" to "00FFFF",
"darkblue" to "00008B", "darkcyan" to "008B8B", "darkgoldenrod" to "B8860B",
"darkgray" to "A9A9A9", "darkgreen" to "006400", "darkgrey" to "A9A9A9",
"darkkhaki" to "BDB76B", "darkmagenta" to "8B008B", "darkolivegreen" to "556B2F",
"darkorange" to "FF8C00", "darkorchid" to "9932CC", "darkred" to "8B0000",
"darksalmon" to "E9967A", "darkseagreen" to "8FBC8F", "darkslateblue" to "483D8B",
"darkslategray" to "2F4F4F", "darkslategrey" to "2F4F4F", "darkturquoise" to "00CED1",
"darkviolet" to "9400D3", "deeppink" to "FF1493", "deepskyblue" to "00BFFF",
"dimgray" to "696969", "dimgrey" to "696969", "dodgerblue" to "1E90FF",
"firebrick" to "B22222", "floralwhite" to "FFFAF0", "forestgreen" to "228B22",
"fuchsia" to "FF00FF", "gainsboro" to "DCDCDC", "ghostwhite" to "F8F8FF",
"gold" to "FFD700", "goldenrod" to "DAA520", "gray" to "808080",
"green" to "008000", "greenyellow" to "ADFF2F", "grey" to "808080",
"honeydew" to "F0FFF0", "hotpink" to "FF69B4", "indianred" to "CD5C5C",
"indigo" to "4B0082", "ivory" to "FFFFF0", "khaki" to "F0E68C",
"lavender" to "E6E6FA", "lavenderblush" to "FFF0F5", "lawngreen" to "7CFC00",
"lemonchiffon" to "FFFACD", "lightblue" to "ADD8E6", "lightcoral" to "F08080",
"lightcyan" to "E0FFFF", "lightgoldenrodyellow" to "FAFAD2", "lightgray" to "D3D3D3",
"lightgreen" to "90EE90", "lightgrey" to "D3D3D3", "lightpink" to "FFB6C1",
"lightsalmon" to "FFA07A", "lightseagreen" to "20B2AA", "lightskyblue" to "87CEFA",
"lightslategray" to "778899", "lightslategrey" to "778899", "lightsteelblue" to "B0C4DE",
"lightyellow" to "FFFFE0", "lime" to "00FF00", "limegreen" to "32CD32",
"linen" to "FAF0E6", "magenta" to "FF00FF", "maroon" to "800000",
"mediumaquamarine" to "66CDAA", "mediumblue" to "0000CD", "mediumorchid" to "BA55D3",
"mediumpurple" to "9370DB", "mediumseagreen" to "3CB371", "mediumslateblue" to "7B68EE",
"mediumspringgreen" to "00FA9A", "mediumturquoise" to "48D1CC", "mediumvioletred" to "C71585",
"midnightblue" to "191970", "mintcream" to "F5FFFA", "mistyrose" to "FFE4E1",
"moccasin" to "FFE4B5", "navajowhite" to "FFDEAD", "navy" to "000080",
"oldlace" to "FDF5E6", "olive" to "808000", "olivedrab" to "6B8E23",
"orange" to "FFA500", "orangered" to "FF4500", "orchid" to "DA70D6",
"palegoldenrod" to "EEE8AA", "palegreen" to "98FB98", "paleturquoise" to "AFEEEE",
"palevioletred" to "DB7093", "papayawhip" to "FFEFD5", "peachpuff" to "FFDAB9",
"peru" to "CD853F", "pink" to "FFC0CB", "plum" to "DDA0DD",
"powderblue" to "B0E0E6", "purple" to "800080", "rebeccapurple" to "663399",
"red" to "FF0000", "rosybrown" to "BC8F8F", "royalblue" to "4169E1",
"saddlebrown" to "8B4513", "salmon" to "FA8072", "sandybrown" to "F4A460",
"seagreen" to "2E8B57", "seashell" to "FFF5EE", "sienna" to "A0522D",
"silver" to "C0C0C0", "skyblue" to "87CEEB", "slateblue" to "6A5ACD",
"slategray" to "708090", "slategrey" to "708090", "snow" to "FFFAFA",
"springgreen" to "00FF7F", "steelblue" to "4682B4", "tan" to "D2B48C",
"teal" to "008080", "thistle" to "D8BFD8", "tomato" to "FF6347",
"turquoise" to "40E0D0", "violet" to "EE82EE", "wheat" to "F5DEB3",
"white" to "FFFFFF", "whitesmoke" to "F5F5F5", "yellow" to "FFFF00",
"yellowgreen" to "9ACD32"
)
private val NAMED: Map<String, Int> = NAMED_HEX
.mapValues { (_, hexRgb) ->
// parse hexRgb ("RRGGBB") to Int, then OR in 0xFF000000 for full opacity
val rgb = hexRgb.toInt(16)
(rgb shl 8) or 0xFF
} + ("transparent" to 0x00000000)
private val HEX_REGEX = Regex("^#([0-9a-fA-F]{3,8})$", RegexOption.IGNORE_CASE)
private val RGB_REGEX = Regex("^rgba?\\(([^)]+)\\)\$", RegexOption.IGNORE_CASE)
private val HSL_REGEX = Regex("^hsla?\\(([^)]+)\\)\$", RegexOption.IGNORE_CASE)
@JvmStatic
fun parseColor(s: String): CSSColor {
val str = s.trim()
// named
NAMED[str.lowercase()]?.let { return it.RGBAtoCSSColor() }
// hex
HEX_REGEX.matchEntire(str)?.groupValues?.get(1)?.let { part ->
return parseHexPart(part)
}
// rgb/rgba
RGB_REGEX.matchEntire(str)?.groupValues?.get(1)?.let {
return parseRgbParts(it.split(',').map(String::trim))
}
// hsl/hsla
HSL_REGEX.matchEntire(str)?.groupValues?.get(1)?.let {
return parseHslParts(it.split(',').map(String::trim))
}
error("Cannot parse color: \"$s\"")
}
private fun parseHexPart(p: String): CSSColor {
// expand shorthand like "RGB" or "RGBA" to full 8-chars "RRGGBBAA"
val hex = when (p.length) {
3 -> p.map { "$it$it" }.joinToString("") + "FF"
4 -> p.map { "$it$it" }.joinToString("")
6 -> p + "FF"
8 -> p
else -> error("Invalid hex color: #$p")
}
val parsed = hex.toLong(16).toInt()
val alpha = (parsed and 0xFF) shl 24
val rgbOnly = (parsed ushr 8) and 0x00FFFFFF
val argb = alpha or rgbOnly
return fromArgb(argb)
}
private fun parseRgbParts(parts: List<String>): CSSColor {
require(parts.size == 3 || parts.size == 4) { "rgb/rgba needs 3 or 4 parts" }
// r/g/b: "128" → 128/255, "50%" → 0.5
fun channel(ch: String): Float =
if (ch.endsWith("%")) ch.removeSuffix("%").toFloat() / 100f
else ch.toFloat().coerceIn(0f, 255f) / 255f
// alpha: "0.5" → 0.5, "50%" → 0.5
fun alpha(a: String): Float =
if (a.endsWith("%")) a.removeSuffix("%").toFloat() / 100f
else a.toFloat().coerceIn(0f, 1f)
val r = channel(parts[0])
val g = channel(parts[1])
val b = channel(parts[2])
val a = if (parts.size == 4) alpha(parts[3]) else 1f
return CSSColor(r, g, b, a)
}
private fun parseHslParts(parts: List<String>): CSSColor {
require(parts.size == 3 || parts.size == 4) { "hsl/hsla needs 3 or 4 parts" }
fun hueOf(h: String): Float = when {
h.endsWith("deg") -> h.removeSuffix("deg").toFloat()
h.endsWith("grad") -> h.removeSuffix("grad").toFloat() * 0.9f
h.endsWith("rad") -> h.removeSuffix("rad").toFloat() * (180f / PI.toFloat())
h.endsWith("turn") -> h.removeSuffix("turn").toFloat() * 360f
else -> h.toFloat()
}
// for s and l you only ever see percentages
fun pct(p: String): Float =
p.removeSuffix("%").toFloat().coerceIn(0f, 100f) / 100f
// alpha: "0.5" → 0.5, "50%" → 0.5
fun alpha(a: String): Float =
if (a.endsWith("%")) pct(a)
else a.toFloat().coerceIn(0f, 1f)
val h = hueOf(parts[0])
val s = pct(parts[1])
val l = pct(parts[2])
val a = if (parts.size == 4) alpha(parts[3]) else 1f
return CSSColor(0f, 0f, 0f, a).apply { setHsl(h, s, l) }
}
}
}
fun Int.RGBAtoCSSColor(): CSSColor = CSSColor.fromRgba(this)
fun Int.ARGBtoCSSColor(): CSSColor = CSSColor.fromArgb(this)
fun CSSColor.toAndroidColor(): Int = toArgbInt()

View file

@ -18,8 +18,7 @@ class LiveEventEmojis: IPlatformLiveEvent {
fun fromV8(config: IV8PluginConfig, obj: V8ValueObject) : LiveEventEmojis { fun fromV8(config: IV8PluginConfig, obj: V8ValueObject) : LiveEventEmojis {
obj.ensureIsBusy(); obj.ensureIsBusy();
val contextName = "LiveEventEmojis" val contextName = "LiveEventEmojis"
return LiveEventEmojis( return LiveEventEmojis(obj.getOrThrow(config, "emojis", contextName));
obj.getOrThrow(config, "emojis", contextName));
} }
} }
} }

View file

@ -3,6 +3,7 @@ package com.futo.platformplayer.api.media.models.live
import com.caoccao.javet.values.reference.V8ValueObject import com.caoccao.javet.values.reference.V8ValueObject
import com.futo.platformplayer.engine.IV8PluginConfig import com.futo.platformplayer.engine.IV8PluginConfig
import com.futo.platformplayer.ensureIsBusy import com.futo.platformplayer.ensureIsBusy
import com.futo.platformplayer.getOrDefault
import com.futo.platformplayer.getOrThrow import com.futo.platformplayer.getOrThrow
class LiveEventRaid: IPlatformLiveEvent { class LiveEventRaid: IPlatformLiveEvent {
@ -11,11 +12,13 @@ class LiveEventRaid: IPlatformLiveEvent {
val targetName: String; val targetName: String;
val targetThumbnail: String; val targetThumbnail: String;
val targetUrl: String; val targetUrl: String;
val isOutgoing: Boolean;
constructor(name: String, url: String, thumbnail: String) { constructor(name: String, url: String, thumbnail: String, isOutgoing: Boolean) {
this.targetName = name; this.targetName = name;
this.targetUrl = url; this.targetUrl = url;
this.targetThumbnail = thumbnail; this.targetThumbnail = thumbnail;
this.isOutgoing = isOutgoing;
} }
companion object { companion object {
@ -25,7 +28,8 @@ class LiveEventRaid: IPlatformLiveEvent {
return LiveEventRaid( return LiveEventRaid(
obj.getOrThrow(config, "targetName", contextName), obj.getOrThrow(config, "targetName", contextName),
obj.getOrThrow(config, "targetUrl", contextName), obj.getOrThrow(config, "targetUrl", contextName),
obj.getOrThrow(config, "targetThumbnail", contextName)); obj.getOrThrow(config, "targetThumbnail", contextName),
obj.getOrDefault<Boolean>(config, "isOutgoing", contextName, true) ?: true);
} }
} }
} }

View file

@ -43,7 +43,6 @@ class HistoryListViewHolder : ViewHolder {
constructor(viewGroup: ViewGroup) : super(LayoutInflater.from(viewGroup.context).inflate(R.layout.list_history, viewGroup, false)) { constructor(viewGroup: ViewGroup) : super(LayoutInflater.from(viewGroup.context).inflate(R.layout.list_history, viewGroup, false)) {
_root = itemView.findViewById(R.id.root); _root = itemView.findViewById(R.id.root);
_imageThumbnail = itemView.findViewById(R.id.image_video_thumbnail); _imageThumbnail = itemView.findViewById(R.id.image_video_thumbnail);
_imageThumbnail.clipToOutline = true;
_textName = itemView.findViewById(R.id.text_video_name); _textName = itemView.findViewById(R.id.text_video_name);
_textAuthor = itemView.findViewById(R.id.text_author); _textAuthor = itemView.findViewById(R.id.text_author);
_textMetadata = itemView.findViewById(R.id.text_video_metadata); _textMetadata = itemView.findViewById(R.id.text_video_metadata);

View file

@ -51,7 +51,6 @@ class VideoListEditorViewHolder : ViewHolder {
constructor(view: View, touchHelper: ItemTouchHelper? = null) : super(view) { constructor(view: View, touchHelper: ItemTouchHelper? = null) : super(view) {
_root = view.findViewById(R.id.root); _root = view.findViewById(R.id.root);
_imageThumbnail = view.findViewById(R.id.image_video_thumbnail); _imageThumbnail = view.findViewById(R.id.image_video_thumbnail);
_imageThumbnail?.clipToOutline = true;
_textName = view.findViewById(R.id.text_video_name); _textName = view.findViewById(R.id.text_video_name);
_textAuthor = view.findViewById(R.id.text_author); _textAuthor = view.findViewById(R.id.text_author);
_textMetadata = view.findViewById(R.id.text_video_metadata); _textMetadata = view.findViewById(R.id.text_video_metadata);

View file

@ -29,7 +29,6 @@ class VideoListHorizontalViewHolder : ViewHolder {
constructor(view: View) : super(view) { constructor(view: View) : super(view) {
_root = view.findViewById(R.id.root); _root = view.findViewById(R.id.root);
_imageThumbnail = view.findViewById(R.id.image_video_thumbnail); _imageThumbnail = view.findViewById(R.id.image_video_thumbnail);
_imageThumbnail?.clipToOutline = true;
_textName = view.findViewById(R.id.text_video_name); _textName = view.findViewById(R.id.text_video_name);
_textAuthor = view.findViewById(R.id.text_author); _textAuthor = view.findViewById(R.id.text_author);
_textVideoDuration = view.findViewById(R.id.thumbnail_duration); _textVideoDuration = view.findViewById(R.id.thumbnail_duration);

View file

@ -1,5 +1,6 @@
package com.futo.platformplayer.views.livechat package com.futo.platformplayer.views.livechat
import CSSColor
import android.graphics.Color import android.graphics.Color
import android.graphics.drawable.LevelListDrawable import android.graphics.drawable.LevelListDrawable
import android.text.Spannable import android.text.Spannable
@ -24,6 +25,7 @@ import com.futo.platformplayer.views.adapters.AnyAdapter
import com.futo.platformplayer.views.overlays.LiveChatOverlay import com.futo.platformplayer.views.overlays.LiveChatOverlay
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import toAndroidColor
class LiveChatDonationListItem(viewGroup: ViewGroup) class LiveChatDonationListItem(viewGroup: ViewGroup)
: LiveChatListItem(LayoutInflater.from(viewGroup.context).inflate(R.layout.list_chat_donation, viewGroup, false)) { : LiveChatListItem(LayoutInflater.from(viewGroup.context).inflate(R.layout.list_chat_donation, viewGroup, false)) {
@ -55,10 +57,10 @@ class LiveChatDonationListItem(viewGroup: ViewGroup)
_amount.text = event.amount.trim(); _amount.text = event.amount.trim();
if(event.colorDonation != null && event.colorDonation.isHexColor()) { if(event.colorDonation != null && event.colorDonation.isHexColor()) {
val color = Color.parseColor(event.colorDonation); val color = CSSColor.parseColor(event.colorDonation);
_amountContainer.background.setTint(color); _amountContainer.background.setTint(color.toAndroidColor());
if((color.green > 140 || color.red > 140 || color.blue > 140) && (color.red + color.green + color.blue) > 400) if(color.lightness > 0.5)
_amount.setTextColor(Color.BLACK); _amount.setTextColor(Color.BLACK);
else else
_amount.setTextColor(Color.WHITE); _amount.setTextColor(Color.WHITE);

View file

@ -13,6 +13,7 @@ import com.bumptech.glide.Glide
import com.futo.platformplayer.R import com.futo.platformplayer.R
import com.futo.platformplayer.api.media.models.live.LiveEventDonation import com.futo.platformplayer.api.media.models.live.LiveEventDonation
import com.futo.platformplayer.isHexColor import com.futo.platformplayer.isHexColor
import toAndroidColor
class LiveChatDonationPill: LinearLayout { class LiveChatDonationPill: LinearLayout {
private val _imageAuthor: ImageView; private val _imageAuthor: ImageView;
@ -33,10 +34,10 @@ class LiveChatDonationPill: LinearLayout {
if(donation.colorDonation != null && donation.colorDonation.isHexColor()) { if(donation.colorDonation != null && donation.colorDonation.isHexColor()) {
val color = Color.parseColor(donation.colorDonation); val color = CSSColor.parseColor(donation.colorDonation);
root.background.setTint(color); root.background.setTint(color.toAndroidColor());
if((color.green > 140 || color.red > 140 || color.blue > 140) && (color.red + color.green + color.blue) > 400) if(color.lightness > 0.5)
_textAmount.setTextColor(Color.BLACK); _textAmount.setTextColor(Color.BLACK);
else else
_textAmount.setTextColor(Color.WHITE); _textAmount.setTextColor(Color.WHITE);

View file

@ -18,6 +18,7 @@ import com.futo.platformplayer.views.adapters.AnyAdapter
import com.futo.platformplayer.views.overlays.LiveChatOverlay import com.futo.platformplayer.views.overlays.LiveChatOverlay
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import toAndroidColor
class LiveChatMessageListItem(viewGroup: ViewGroup) class LiveChatMessageListItem(viewGroup: ViewGroup)
: LiveChatListItem(LayoutInflater.from(viewGroup.context).inflate(R.layout.list_chat_message, viewGroup, false)) { : LiveChatListItem(LayoutInflater.from(viewGroup.context).inflate(R.layout.list_chat_message, viewGroup, false)) {
@ -75,7 +76,7 @@ class LiveChatMessageListItem(viewGroup: ViewGroup)
if (!event.colorName.isNullOrEmpty()) { if (!event.colorName.isNullOrEmpty()) {
try { try {
_authorName.setTextColor(Color.parseColor(event.colorName)); _authorName.setTextColor(CSSColor.parseColor(event.colorName).toAndroidColor());
} catch (ex: Throwable) { } catch (ex: Throwable) {
} }
} else } else

View file

@ -14,9 +14,6 @@ import android.widget.ImageView
import android.widget.LinearLayout import android.widget.LinearLayout
import android.widget.TextView import android.widget.TextView
import androidx.constraintlayout.widget.ConstraintLayout import androidx.constraintlayout.widget.ConstraintLayout
import androidx.core.graphics.blue
import androidx.core.graphics.green
import androidx.core.graphics.red
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.LinearSmoothScroller import androidx.recyclerview.widget.LinearSmoothScroller
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
@ -43,6 +40,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.serialization.encodeToString import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import toAndroidColor
class LiveChatOverlay : LinearLayout { class LiveChatOverlay : LinearLayout {
@ -69,7 +67,7 @@ class LiveChatOverlay : LinearLayout {
private val _overlayRaid_Thumbnail: ImageView; private val _overlayRaid_Thumbnail: ImageView;
private val _overlayRaid_ButtonGo: Button; private val _overlayRaid_ButtonGo: Button;
private val _overlayRaid_ButtonPrevent: Button; private val _overlayRaid_ButtonDismiss: Button;
private val _textViewers: TextView; private val _textViewers: TextView;
@ -150,7 +148,7 @@ class LiveChatOverlay : LinearLayout {
_overlayRaid_Name = findViewById(R.id.raid_name); _overlayRaid_Name = findViewById(R.id.raid_name);
_overlayRaid_Thumbnail = findViewById(R.id.raid_thumbnail); _overlayRaid_Thumbnail = findViewById(R.id.raid_thumbnail);
_overlayRaid_ButtonGo = findViewById(R.id.raid_button_go); _overlayRaid_ButtonGo = findViewById(R.id.raid_button_go);
_overlayRaid_ButtonPrevent = findViewById(R.id.raid_button_prevent); _overlayRaid_ButtonDismiss = findViewById(R.id.raid_button_prevent);
_overlayRaid.visibility = View.GONE; _overlayRaid.visibility = View.GONE;
@ -159,7 +157,7 @@ class LiveChatOverlay : LinearLayout {
onRaidNow.emit(it); onRaidNow.emit(it);
} }
} }
_overlayRaid_ButtonPrevent.setOnClickListener { _overlayRaid_ButtonDismiss.setOnClickListener {
_currentRaid?.let { _currentRaid?.let {
_currentRaid = null; _currentRaid = null;
_overlayRaid.visibility = View.GONE; _overlayRaid.visibility = View.GONE;
@ -291,10 +289,10 @@ class LiveChatOverlay : LinearLayout {
_overlayDonation_Amount.text = donation.amount.trim(); _overlayDonation_Amount.text = donation.amount.trim();
_overlayDonation.visibility = VISIBLE; _overlayDonation.visibility = VISIBLE;
if(donation.colorDonation != null && donation.colorDonation.isHexColor()) { if(donation.colorDonation != null && donation.colorDonation.isHexColor()) {
val color = Color.parseColor(donation.colorDonation); val color = CSSColor.parseColor(donation.colorDonation);
_overlayDonation_AmountContainer.background.setTint(color); _overlayDonation_AmountContainer.background.setTint(color.toAndroidColor());
if((color.green > 140 || color.red > 140 || color.blue > 140) && (color.red + color.green + color.blue) > 400) if(color.lightness > 0.5)
_overlayDonation_Amount.setTextColor(Color.BLACK) _overlayDonation_Amount.setTextColor(Color.BLACK)
else else
_overlayDonation_Amount.setTextColor(Color.WHITE); _overlayDonation_Amount.setTextColor(Color.WHITE);
@ -372,6 +370,8 @@ class LiveChatOverlay : LinearLayout {
} }
else else
_overlayRaid.visibility = View.GONE; _overlayRaid.visibility = View.GONE;
_overlayRaid_ButtonGo.visibility = if (raid?.isOutgoing == true) View.VISIBLE else View.GONE
} }
} }
fun setViewCount(viewCount: Int) { fun setViewCount(viewCount: Int) {

View file

@ -263,8 +263,8 @@
android:textSize="13dp" android:textSize="13dp"
android:letterSpacing="0" android:letterSpacing="0"
android:fontFamily="@font/inter_regular" android:fontFamily="@font/inter_regular"
android:layout_marginStart="20dp" android:layout_marginStart="5dp"
android:backgroundTint="#2F2F2F" android:backgroundTint="@color/colorPrimary"
android:layout_marginEnd="5dp" android:layout_marginEnd="5dp"
android:text="@string/go_now"/> android:text="@string/go_now"/>
<Button <Button
@ -277,9 +277,9 @@
android:textSize="13dp" android:textSize="13dp"
android:letterSpacing="0" android:letterSpacing="0"
android:layout_marginStart="5dp" android:layout_marginStart="5dp"
android:layout_marginEnd="20dp" android:layout_marginEnd="5dp"
android:backgroundTint="#481414" android:backgroundTint="#2F2F2F"
android:text="@string/prevent"/> android:text="@string/dismiss"/>
</LinearLayout> </LinearLayout>
</androidx.constraintlayout.widget.ConstraintLayout> </androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -0,0 +1,257 @@
package com.futo.platformplayer
import CSSColor
import org.junit.Assert.assertEquals
import kotlin.math.PI
import kotlin.math.abs
import kotlin.test.Test
import kotlin.test.assertTrue
class CSSColorTest {
private fun approxEq(expected: Float, actual: Float, eps: Float = 1e-5f) {
assertTrue(abs(expected - actual) <= eps, "Expected $expected but got $actual")
}
@Test fun `hex #RRGGBB parses correctly`() {
val c = CSSColor.parseColor("#336699")
assertEquals(0x33, c.red)
assertEquals(0x66, c.green)
assertEquals(0x99, c.blue)
assertEquals(255, c.alpha)
}
@Test fun `hex #RGB shorthand expands`() {
val c = CSSColor.parseColor("#369")
assertEquals(0x33, c.red)
assertEquals(0x66, c.green)
assertEquals(0x99, c.blue)
}
@Test fun `hex #RRGGBBAA parses alpha`() {
val c = CSSColor.parseColor("#33669980")
assertEquals(0x33, c.red)
assertEquals(0x66, c.green)
assertEquals(0x99, c.blue)
approxEq(128 / 255f, c.a)
assertEquals(128, c.alpha)
}
@Test fun `hex #RGBA shorthand expands with alpha`() {
val c = CSSColor.parseColor("#3698")
assertEquals(0x33, c.red)
assertEquals(0x66, c.green)
assertEquals(0x99, c.blue)
assertEquals(0x88, c.alpha)
}
@Test fun `hex uppercase shorthand parses`() {
val c = CSSColor.parseColor("#AbC")
// expands to AABBCC
assertEquals(0xAA, c.red)
assertEquals(0xBB, c.green)
assertEquals(0xCC, c.blue)
}
@Test fun `rgb(ints) functional parser`() {
val c = CSSColor.parseColor("rgb(255,128,0)")
assertEquals(255, c.red)
assertEquals(128, c.green)
assertEquals(0, c.blue)
assertEquals(255, c.alpha)
}
@Test fun `rgb(percent) functional parser`() {
val c = CSSColor.parseColor("rgb(100%,50%,0%)")
assertEquals(255, c.red)
assertEquals(128, c.green)
assertEquals(0, c.blue)
}
@Test fun `rgba rawfloat alpha functional parser`() {
val c = CSSColor.parseColor("rgba(255,0,0,0.5)")
assertEquals(255, c.red)
assertEquals(0, c.green)
assertEquals(0, c.blue)
approxEq(0.5f, c.a)
}
@Test fun `rgba percent alpha functional parser`() {
val c = CSSColor.parseColor("rgba(100%,0%,0%,50%)")
assertEquals(255, c.red)
assertEquals(0, c.green)
assertEquals(0, c.blue)
approxEq(0.5f, c.a)
}
@Test fun `hsl() functional parser yields correct RGB`() {
// pure green: hue=120°, sat=100%, light=50%
val c = CSSColor.parseColor("hsl(120,100%,50%)")
assertEquals(0, c.red)
assertEquals(255, c.green)
assertEquals(0, c.blue)
}
@Test fun `hsla percent alpha functional parser`() {
val c = CSSColor.parseColor("hsla(240,100%,50%,25%)")
// pure blue, alpha 25%
assertEquals(0, c.red)
assertEquals(0, c.green)
assertEquals(255, c.blue)
approxEq(0.25f, c.a)
}
@Test fun `hsla rawfloat alpha functional parser`() {
val c = CSSColor.parseColor("hsla(240,100%,50%,0.25)")
assertEquals(0, c.red)
assertEquals(0, c.green)
assertEquals(255, c.blue)
approxEq(0.25f, c.a)
}
@Test fun `hsl radian unit parsing`() {
// 180° = π radians → cyan
val c = CSSColor.parseColor("hsl(${PI}rad,100%,50%)")
assertEquals(0, c.red)
assertEquals(255, c.green)
assertEquals(255, c.blue)
}
@Test fun `hsl turn unit parsing`() {
// 0.5 turn = 180° → cyan
val c = CSSColor.parseColor("hsl(0.5turn,100%,50%)")
assertEquals(0, c.red)
assertEquals(255, c.green)
assertEquals(255, c.blue)
}
@Test fun `hsl grad unit parsing`() {
// 200 grad = 180° → cyan
val c = CSSColor.parseColor("hsl(200grad,100%,50%)")
assertEquals(0, c.red)
assertEquals(255, c.green)
assertEquals(255, c.blue)
}
@Test fun `named colors parse`() {
val red = CSSColor.parseColor("red")
assertEquals(255, red.red)
assertEquals(0, red.green)
assertEquals(0, red.blue)
val rebecca = CSSColor.parseColor("rebeccapurple")
assertEquals(0x66, rebecca.red)
assertEquals(0x33, rebecca.green)
assertEquals(0x99, rebecca.blue)
val transparent = CSSColor.parseColor("transparent")
assertEquals(0, transparent.alpha)
}
@Test fun `round-trip Android Int ↔ CSSColor`() {
val original = CSSColor(0.2f, 0.4f, 0.6f, 0.8f)
val colorInt = original.toRgbaInt()
val back = CSSColor.fromRgba(colorInt)
approxEq(original.r, back.r)
approxEq(original.g, back.g)
approxEq(original.b, back.b)
approxEq(original.a, back.a)
}
@Test fun `individual channel setters`() {
val c = CSSColor(0f,0f,0f,1f)
c.red = 128; assertEquals(128, c.red); approxEq(128/255f, c.r)
c.green = 64; assertEquals(64, c.green); approxEq(64/255f, c.g)
c.blue = 32; assertEquals(32, c.blue); approxEq(32/255f, c.b)
c.alpha = 200; assertEquals(200, c.alpha); approxEq(200/255f, c.a)
}
@Test fun `hsl channel setters update RGB`() {
val c = CSSColor.parseColor("hsl(0,100%,50%)") // red
c.hue = 120f // → green
assertEquals(0, c.red)
assertEquals(255, c.green)
assertEquals(0, c.blue)
c.saturation = 0f // → gray
assertTrue(c.red == c.green && c.green == c.blue)
}
@Test fun `convenience modifiers chain as expected`() {
val c = CSSColor.parseColor("#888888")
.lighten(0.1f)
.saturate(0.2f)
.rotateHue(45f)
approxEq(0.633f, c.lightness, eps = 1e-3f)
approxEq(0.2f, c.saturation, eps = 1e-3f)
approxEq(45f, c.hue)
}
@Test
fun `invalid formats throw IllegalArgumentException`() {
listOf("", "rgb()", "hsl(0,0)", "#12", "rgba(0,0,0,150%)", "hsla(0,0%,0%,2)").forEach { bad ->
try {
CSSColor.parseColor(bad)
assert(false)
} catch (e: Throwable) {
}
}
}
@Test fun `outofrange RGB ints clamp`() {
val c = CSSColor.parseColor("rgb(300,-20, 260)")
assertEquals(255, c.red)
assertEquals(0, c.green)
assertEquals(255, c.blue)
}
@Test fun `parser is case- and whitespace-tolerant`() {
val a = CSSColor.parseColor(" RgB( 10 ,20, 30 )")
assertEquals(10, a.red)
assertEquals(20, a.green)
assertEquals(30, a.blue)
val b = CSSColor.parseColor(" ReBeCcaPURple ")
assertEquals(0x66, b.red)
assertEquals(0x33, b.green)
assertEquals(0x99, b.blue)
}
@Test fun `hsl lightness extremes`() {
// lightness = 0 → black
val black = CSSColor.parseColor("hsl(123,45%,0%)")
assertEquals(0, black.red)
assertEquals(0, black.green)
assertEquals(0, black.blue)
// lightness = 100% → white
val white = CSSColor.parseColor("hsl(321,55%,100%)")
assertEquals(255, white.red)
assertEquals(255, white.green)
assertEquals(255, white.blue)
// saturation = 0 → gray (r==g==b)
val gray = CSSColor.parseColor("hsl(50,0%,60%)")
assertTrue(gray.red == gray.green && gray.green == gray.blue)
}
@Test fun `hsl negative and large hues wrap`() {
val c1 = CSSColor.parseColor("hsl(-120,100%,50%)") // → same as 240°
assertEquals(0, c1.red)
assertEquals(0, c1.green)
assertEquals(255, c1.blue)
val c2 = CSSColor.parseColor("hsl(480,100%,50%)") // → same as 120°
assertEquals(0, c2.red)
assertEquals(255, c2.green)
assertEquals(0, c2.blue)
}
@Test fun `lighten then darken returns original`() {
val base = CSSColor.parseColor("#123456")
val round = base.lighten(0.2f).darken(0.2f)
approxEq(base.r, round.r)
approxEq(base.g, round.g)
approxEq(base.b, round.b)
}
}

View file

@ -26,7 +26,7 @@ import java.util.Random
import java.util.concurrent.CountDownLatch import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
/*
class NoiseProtocolTest { class NoiseProtocolTest {
constructor() { constructor() {
Logger.setLogConsumers(listOf( Logger.setLogConsumers(listOf(
@ -625,4 +625,4 @@ class NoiseProtocolTest {
throw Exception("Byte mismatch at index ${i}") throw Exception("Byte mismatch at index ${i}")
} }
} }
} }*/