Skip to content

Commit

Permalink
Support highlighting new bit badge notifications (#869)
Browse files Browse the repository at this point in the history
* Simplify icon generation

* Cleanup

* Add support for bit badge tier notifications

* More simplification

* Even more simplification
  • Loading branch information
ScrubN authored Oct 21, 2023
1 parent f4fcaf2 commit d6dbe78
Show file tree
Hide file tree
Showing 2 changed files with 73 additions and 73 deletions.
56 changes: 48 additions & 8 deletions TwitchDownloaderCore/ChatRenderer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,7 @@ public sealed class ChatRenderer : IDisposable
public bool Disposed { get; private set; } = false;
public ChatRoot chatRoot { get; private set; } = new ChatRoot();

private const string PURPLE = "#7B2CF2";
private static readonly SKColor Purple = SKColor.Parse(PURPLE);
private static readonly SKColor Purple = SKColor.Parse("#7B2CF2");
private static readonly string[] DefaultUsernameColors = { "#FF0000", "#0000FF", "#00FF00", "#B22222", "#FF7F50", "#9ACD32", "#FF4500", "#2E8B57", "#DAA520", "#D2691E", "#5F9EA0", "#1E90FF", "#FF69B4", "#8A2BE2", "#00FF7F" };

private static readonly Regex RtlRegex = new("[\u0591-\u07FF\uFB1D-\uFDFD\uFE70-\uFEFC]", RegexOptions.Compiled);
Expand Down Expand Up @@ -704,6 +703,9 @@ private void DrawAccentedMessage(Comment comment, List<(SKImageInfo info, SKBitm
case HighlightType.SubscribedPrime:
DrawSubscribeMessage(comment, sectionImages, emotePositionList, ref drawPos, defaultPos, highlightIcon, iconPoint);
break;
case HighlightType.BitBadgeTierNotification:
DrawBitsBadgeTierMessage(comment, sectionImages, emotePositionList, ref drawPos, defaultPos, highlightIcon, iconPoint);
break;
case HighlightType.GiftedMany:
case HighlightType.GiftedSingle:
case HighlightType.GiftedAnonymous:
Expand Down Expand Up @@ -735,7 +737,7 @@ private void DrawSubscribeMessage(Comment comment, List<(SKImageInfo info, SKBit
drawPos.X += highlightIcon.Width + renderOptions.WordSpacing;
defaultPos.X = drawPos.X;

DrawUsername(comment, sectionImages, ref drawPos, defaultPos, false, PURPLE);
DrawUsername(comment, sectionImages, ref drawPos, defaultPos, false, Purple);
AddImageSection(sectionImages, ref drawPos, defaultPos);

// Remove the commenter's name from the resub message
Expand Down Expand Up @@ -765,6 +767,41 @@ private void DrawSubscribeMessage(Comment comment, List<(SKImageInfo info, SKBit
DrawNonAccentedMessage(customResubMessage, sectionImages, emotePositionList, false, ref drawPos, ref defaultPos);
}

private void DrawBitsBadgeTierMessage(Comment comment, List<(SKImageInfo info, SKBitmap bitmap)> sectionImages, List<(Point, TwitchEmote)> emotePositionList, ref Point drawPos, Point defaultPos, SKImage highlightIcon, Point iconPoint)
{
using SKCanvas canvas = new(sectionImages.Last().bitmap);

canvas.DrawImage(highlightIcon, iconPoint.X, iconPoint.Y);
drawPos.X += highlightIcon.Width + renderOptions.WordSpacing;
defaultPos.X = drawPos.X;

if (comment.message.fragments.Count == 1)
{
DrawUsername(comment, sectionImages, ref drawPos, defaultPos, false, messageFont.Color);

var bitsBadgeVersion = comment.message.user_badges.FirstOrDefault(x => x._id == "bits")?.version;
if (bitsBadgeVersion is not null)
{
comment.message.body = bitsBadgeVersion.Length > 3
? $"just earned a new {bitsBadgeVersion.AsSpan(0, bitsBadgeVersion.Length - 3)}K Bits badge!"
: $"just earned a new {bitsBadgeVersion} Bits badge!";
}
else
{
comment.message.body = "just earned a new Bits badge!";
}

comment.message.fragments[0].text = comment.message.body;
}
else
{
// This should never be possible, but just in case.
DrawUsername(comment, sectionImages, ref drawPos, defaultPos, true, messageFont.Color);
}

DrawMessage(comment, sectionImages, emotePositionList, false, ref drawPos, defaultPos);
}

private void DrawGiftMessage(Comment comment, List<(SKImageInfo info, SKBitmap bitmap)> sectionImages, List<(Point, TwitchEmote)> emotePositionList, ref Point drawPos, Point defaultPos, SKImage highlightIcon, Point iconPoint)
{
using SKCanvas canvas = new(sectionImages.Last().bitmap);
Expand Down Expand Up @@ -1291,22 +1328,25 @@ private static float MeasureRtlText(ReadOnlySpan<char> rtlText, SKPaint textFont
return measure.Width;
}

private void DrawUsername(Comment comment, List<(SKImageInfo info, SKBitmap bitmap)> sectionImages, ref Point drawPos, Point defaultPos, bool appendColon = true, string colorOverride = null)
private void DrawUsername(Comment comment, List<(SKImageInfo info, SKBitmap bitmap)> sectionImages, ref Point drawPos, Point defaultPos, bool appendColon = true, SKColor? colorOverride = null)
{
SKColor userColor = SKColor.Parse(colorOverride ?? comment.message.user_color ?? DefaultUsernameColors[Math.Abs(comment.commenter.display_name.GetHashCode()) % DefaultUsernameColors.Length]);
var userColor = colorOverride ?? SKColor.Parse(comment.message.user_color ?? DefaultUsernameColors[Math.Abs(comment.commenter.display_name.GetHashCode()) % DefaultUsernameColors.Length]);
if (colorOverride is null)
userColor = GenerateUserColor(userColor, renderOptions.BackgroundColor, renderOptions);
userColor = AdjustColorVisibility(userColor, renderOptions.BackgroundColor, renderOptions);

using SKPaint userPaint = comment.commenter.display_name.Any(IsNotAscii)
? GetFallbackFont(comment.commenter.display_name.First(IsNotAscii)).Clone()
: nameFont.Clone();

userPaint.Color = userColor;
string userName = comment.commenter.display_name + (appendColon ? ":" : "");
var userName = appendColon
? comment.commenter.display_name + ":"
: comment.commenter.display_name;

DrawText(userName, userPaint, true, sectionImages, ref drawPos, defaultPos, false);
}

private static SKColor GenerateUserColor(SKColor userColor, SKColor backgroundColor, ChatRenderOptions renderOptions)
private static SKColor AdjustColorVisibility(SKColor userColor, SKColor backgroundColor, ChatRenderOptions renderOptions)
{
backgroundColor.ToHsl(out _, out _, out float backgroundBrightness);
userColor.ToHsl(out float userHue, out float userSaturation, out float userBrightness);
Expand Down
90 changes: 25 additions & 65 deletions TwitchDownloaderCore/Tools/HighlightIcons.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,27 +18,30 @@ public enum HighlightType
PayingForward,
ChannelPointHighlight,
Raid,
BitBadgeTierNotification,
Unknown
}

public sealed class HighlightIcons : IDisposable
{
public bool Disposed { get; private set; } = false;
public bool Disposed { get; private set; }

private const string SUBSCRIBED_TIER_ICON_SVG = "m 32.599229,13.144498 c 1.307494,-2.80819 5.494049,-2.80819 6.80154,0 l 5.648628,12.140919 13.52579,1.877494 c 3.00144,0.418654 4.244522,3.893468 2.138363,5.967405 -3.357829,3.309501 -6.715662,6.618992 -10.073491,9.928491 L 53.07148,56.81637 c 0.524928,2.962772 -2.821092,5.162303 -5.545572,3.645496 L 36,54.043603 24.474093,60.461866 C 21.749613,61.975455 18.403591,59.779142 18.92852,56.81637 L 21.359942,43.058807 11.286449,33.130316 c -2.1061588,-2.073937 -0.863074,-5.548751 2.138363,-5.967405 l 13.52579,-1.877494 z";
private const string SUBSCRIBED_PRIME_ICON_SVG = "m 61.894653,21.663055 v 25.89488 c 0,3.575336 -2.898361,6.47372 -6.473664,6.47372 H 16.57901 c -3.573827,-0.0036 -6.470094,-2.89986 -6.473663,-6.47372 V 21.663055 L 23.052674,31.373635 36,18.426194 c 4.315772,4.315816 8.631553,8.631629 12.947323,12.947441 z";
private const string GIFTED_SINGLE_ICON_SVG = "m 55.187956,23.24523 h 6.395987 V 42.433089 H 58.38595 V 61.620947 H 13.614042 V 42.433089 H 10.416049 V 23.24523 h 6.395987 v -3.859957 c 0,-8.017328 9.689919,-12.0307888 15.359963,-6.363975 0.418936,0.418935 0.796298,0.879444 1.125692,1.371934 l 2.702305,4.055034 2.702305,-4.055034 a 8.9863623,8.9863139 0 0 1 1.125692,-1.371934 c 5.666845,-5.6668138 15.359963,-1.653353 15.359963,6.363975 z M 23.208023,19.385273 v 3.859957 h 8.301992 l -3.536982,-5.305444 a 2.6031666,2.6031528 0 0 0 -4.76501,1.445487 z m 25.583946,0 v 3.859957 h -8.301991 l 3.536983,-5.305444 a 2.6031666,2.6031528 0 0 1 4.765008,1.442286 z m 6.395987,10.255909 v 6.395951 H 39.19799 v -6.395951 z m -3.197992,25.58381 V 42.433089 H 39.19799 V 55.224992 Z M 32.802003,29.641182 v 6.395951 H 16.812036 v -6.395951 z m 0,12.791907 H 20.010028 v 12.791903 h 12.791975 z";
private const string GIFTED_MANY_ICON_URL = "https://static-cdn.jtvnw.net/subs-image-assets/gift-illus.png";
private const string GIFTED_ANONYMOUS_ICON_SVG = "m 54.571425,64.514958 a 4.3531428,4.2396967 0 0 1 -1.273998,-0.86096 l -1.203426,-1.172067 a 7.0051428,6.822584 0 0 0 -9.90229,0 c -3.417139,3.328092 -8.962569,3.328092 -12.383427,0 l -0.159707,-0.155553 a 7.1871427,6.9998405 0 0 0 -9.854005,-0.28216 l -1.894286,1.635103 a 4.9362858,4.8076423 0 0 1 -3.276,1.215474 H 10 V 32.337399 a 26.000001,25.322423 0 0 1 52,0 v 32.557396 h -5.627146 c -0.627714,0 -1.240569,-0.133847 -1.801429,-0.379837 z M 35.999996,14.249955 A 18.571428,18.087444 0 0 0 17.428572,32.337399 v 22.515245 a 14.619428,14.238435 0 0 1 17.471998,2.358609 l 0.163448,0.155554 c 0.516285,0.50645 1.355715,0.50645 1.875712,0 a 14.437428,14.061179 0 0 1 17.631712,-2.11623 V 32.337399 A 18.571428,18.087444 0 0 0 35.999996,14.249955 Z M 24.857142,35.954887 a 3.7142855,3.6174889 0 1 1 7.42857,0 3.7142855,3.6174889 0 0 1 -7.42857,0 z m 18.571432,-3.617488 a 3.7142859,3.6174892 0 1 0 0,7.234978 3.7142859,3.6174892 0 0 0 0,-7.234978 z";
private const string BIT_BADGE_TIER_NOTIFICATION_ICON_SVG = "M 14.242705,42.37453 36,11.292679 57.757295,42.37453 36,61.023641 Z M 22.566425,41.323963 36,22.13092 49.433577,41.317747 46.79162,43.580506 36,39.266345 25.205273,43.586723 22.566425,41.320854 Z";

private static readonly Regex SubMessageRegex = new(@"^(subscribed (?:with Prime|at Tier \d)\. They've subscribed for \d?\d?\d months(?:, currently on a \d?\d?\d month streak)?! )(.+)$", RegexOptions.Compiled);
private static readonly Regex GiftAnonymousRegex = new(@"^An anonymous user (?:gifted a|is gifting \d\d?\d?) Tier \d", RegexOptions.Compiled);
private static readonly Regex SubMessageRegex = new(@"^(subscribed (?:with Prime|at Tier \d)\. They've subscribed for \d{1,3} months(?:, currently on a \d{1,3} month streak)?! )(.+)$", RegexOptions.Compiled);
private static readonly Regex GiftAnonymousRegex = new(@"^An anonymous user (?:gifted a|is gifting \d{1,4}) Tier \d", RegexOptions.Compiled);

private SKImage _subscribedTierIcon;
private SKImage _subscribedPrimeIcon;
private SKImage _giftSingleIcon;
private SKImage _giftManyIcon;
private SKImage _giftAnonymousIcon;
private SKImage _bitBadgeTierNotificationIcon;

private readonly string _cachePath;
private readonly SKColor _purple;
Expand All @@ -54,8 +57,6 @@ public HighlightIcons(string cachePath, SKColor iconPurple, bool offline)
// If it looks like a duck, swims like a duck, and quacks like a duck, then it probably is a duck
public static HighlightType GetHighlightType(Comment comment)
{
const string ANONYMOUS_GIFT_ACCOUNT_ID = "274598607"; // '274598607' is the id of the anonymous gift message account, display name: 'AnAnonymousGifter'

if (comment.message.body.Length == 0)
{
// This likely happens due to the 7TV extension letting users bypass the IRC message trimmer
Expand Down Expand Up @@ -104,68 +105,39 @@ public static HighlightType GetHighlightType(Comment comment)
}
}

if (bodySpan.Equals("bits badge tier notification ", StringComparison.Ordinal))
return HighlightType.BitBadgeTierNotification;

if (char.IsDigit(bodySpan[0]) && bodySpan.Contains("have joined!", StringComparison.Ordinal))
{
// TODO: use bodySpan when .NET 7
if (Regex.IsMatch(comment.message.body, $@"^\d+ raiders from {comment.commenter.display_name} have joined!"))
return HighlightType.Raid;
}

const string ANONYMOUS_GIFT_ACCOUNT_ID = "274598607"; // Display name is 'AnAnonymousGifter'
if (comment.commenter._id is ANONYMOUS_GIFT_ACCOUNT_ID && GiftAnonymousRegex.IsMatch(comment.message.body))
return HighlightType.GiftedAnonymous;

return HighlightType.None;
}

/// <returns>A the requested icon or null if no icon exists for the highlight type</returns>
/// <remarks>The icon returned is NOT a copy and should not be manually disposed.</remarks>
/// <returns>The requested icon or <see langword="null"/> if no icon exists for the highlight type</returns>
/// <remarks>The <see cref="SKImage"/> returned is NOT a copy and should not be manually disposed.</remarks>
public SKImage GetHighlightIcon(HighlightType highlightType, SKColor textColor, double fontSize)
{
// Return the needed icon from cache or generate if null
return highlightType switch
{
HighlightType.SubscribedTier => _subscribedTierIcon ?? GenerateHighlightIcon(highlightType, textColor, fontSize),
HighlightType.SubscribedPrime => _subscribedPrimeIcon ?? GenerateHighlightIcon(highlightType, textColor, fontSize),
HighlightType.GiftedSingle => _giftSingleIcon ?? GenerateHighlightIcon(highlightType, textColor, fontSize),
HighlightType.GiftedMany => _giftManyIcon ?? GenerateHighlightIcon(highlightType, textColor, fontSize),
HighlightType.GiftedAnonymous => _giftAnonymousIcon ?? GenerateHighlightIcon(highlightType, textColor, fontSize),
HighlightType.SubscribedTier => _subscribedTierIcon ??= GenerateSvgIcon(SUBSCRIBED_TIER_ICON_SVG, textColor, fontSize),
HighlightType.SubscribedPrime => _subscribedPrimeIcon ??= GenerateSvgIcon(SUBSCRIBED_PRIME_ICON_SVG, _purple, fontSize),
HighlightType.GiftedSingle => _giftSingleIcon ??= GenerateSvgIcon(GIFTED_SINGLE_ICON_SVG, textColor, fontSize),
HighlightType.GiftedMany => _giftManyIcon ??= GenerateGiftedManyIcon(fontSize, _cachePath, _offline),
HighlightType.GiftedAnonymous => _giftAnonymousIcon ??= GenerateSvgIcon(GIFTED_ANONYMOUS_ICON_SVG, textColor, fontSize),
HighlightType.BitBadgeTierNotification => _bitBadgeTierNotificationIcon ??= GenerateSvgIcon(BIT_BADGE_TIER_NOTIFICATION_ICON_SVG, textColor, fontSize),
_ => null
};
}

private SKImage GenerateHighlightIcon(HighlightType highlightType, SKColor textColor, double fontSize)
{
// Generate the needed icon
var returnIcon = highlightType is HighlightType.GiftedMany
? GenerateGiftedManyIcon(fontSize, _cachePath, _offline)
: GenerateSvgIcon(highlightType, _purple, textColor, fontSize);

// Cache the icon
switch (highlightType)
{
case HighlightType.SubscribedTier:
_subscribedTierIcon = returnIcon;
break;
case HighlightType.SubscribedPrime:
_subscribedPrimeIcon = returnIcon;
break;
case HighlightType.GiftedSingle:
_giftSingleIcon = returnIcon;
break;
case HighlightType.GiftedMany:
_giftManyIcon = returnIcon;
break;
case HighlightType.GiftedAnonymous:
_giftAnonymousIcon = returnIcon;
break;
default:
throw new NotSupportedException("The requested highlight icon does not exist.");
}

// Return the generated icon
return returnIcon;
}

private static SKImage GenerateGiftedManyIcon(double fontSize, string cachePath, bool offline)
{
//int newSize = (int)(fontSize / 0.2727); // 44*44px @ 12pt font // Doesn't work because our image sections aren't tall enough and I'm not rewriting that right now
Expand All @@ -192,36 +164,22 @@ private static SKImage GenerateGiftedManyIcon(double fontSize, string cachePath,
return SKImage.FromBitmap(resizedBitmap);
}

private static SKImage GenerateSvgIcon(HighlightType highlightType, SKColor purple, SKColor textColor, double fontSize)
private static SKImage GenerateSvgIcon(string iconSvgString, SKColor iconColor, double fontSize)
{
using var tempBitmap = new SKBitmap(72, 72); // Icon SVG strings are scaled for 72x72
using var tempCanvas = new SKCanvas(tempBitmap);

using var iconPath = SKPath.ParseSvgPathData(highlightType switch
{
HighlightType.SubscribedTier => SUBSCRIBED_TIER_ICON_SVG,
HighlightType.SubscribedPrime => SUBSCRIBED_PRIME_ICON_SVG,
HighlightType.GiftedSingle => GIFTED_SINGLE_ICON_SVG,
HighlightType.GiftedAnonymous => GIFTED_ANONYMOUS_ICON_SVG,
_ => throw new NotSupportedException("The requested icon svg path does not exist.")
});
using var iconPath = SKPath.ParseSvgPathData(iconSvgString);
iconPath.FillType = SKPathFillType.EvenOdd;

var iconColor = new SKPaint
var iconPaint = new SKPaint
{
Color = highlightType switch
{
HighlightType.SubscribedTier => textColor,
HighlightType.SubscribedPrime => purple,
HighlightType.GiftedSingle => textColor,
HighlightType.GiftedAnonymous => textColor,
_ => throw new NotSupportedException("The requested icon color does not exist.")
},
Color = iconColor,
IsAntialias = true,
LcdRenderText = true
};

tempCanvas.DrawPath(iconPath, iconColor);
tempCanvas.DrawPath(iconPath, iconPaint);
var newSize = (int)(fontSize / 0.6); // 20*20px @ 12pt font
var imageInfo = new SKImageInfo(newSize, newSize);
var resizedBitmap = tempBitmap.Resize(imageInfo, SKFilterQuality.High);
Expand Down Expand Up @@ -309,13 +267,15 @@ private void Dispose(bool isDisposing)
_giftSingleIcon?.Dispose();
_giftManyIcon?.Dispose();
_giftAnonymousIcon?.Dispose();
_bitBadgeTierNotificationIcon?.Dispose();

// Set the root references to null to explicitly tell the garbage collector that the resources have been disposed
_subscribedTierIcon = null;
_subscribedPrimeIcon = null;
_giftSingleIcon = null;
_giftManyIcon = null;
_giftAnonymousIcon = null;
_bitBadgeTierNotificationIcon = null;
}
}
finally
Expand Down

0 comments on commit d6dbe78

Please sign in to comment.