Fixed a few things worked on a couple things situated it so that the menu can be scrolled set color coding and made it so it actually shows the username

This commit is contained in:
minster586
2026-10-26 19:33:33 -04:00
parent 90666d0ac9
commit 5fe1ad4e19
20 changed files with 807 additions and 86 deletions
+1
View File
@@ -3,3 +3,4 @@ build/
run/
*.iml
.idea/
dist/
+96 -14
View File
@@ -4,18 +4,22 @@ A client-side Fabric mod that displays Twitch, YouTube, and TikTok live-stream
events in Minecraft. Twitch and YouTube events are received from Streamer.bot;
TikTok events are received from TikFinity.
## Current status
## Supported Minecraft versions
The project is at its initial implementation stage. The default and currently
verified build target is Minecraft 26.1.2 with Fabric. A separate legacy
client source set is retained for Minecraft 1.20.1.
Each version has its own jar (`stream-chat-<minecraft version>-<mod version>.jar`);
pick the one that matches your game.
| Minecraft | Fabric Loader | Fabric API | Mod Menu | Java |
|-----------|---------------|------------|----------|------|
| 26.1.2 | 0.20.1+ | 0.155.3+26.1.2 | 18.0.2 | 25 |
| 1.20.1 | 0.15.11+ | 0.92.2+1.20.1 | 7.2.2 | 17 |
## Initial setup
1. Install Fabric Loader 0.20.1 and Fabric API 0.155.3 for Minecraft 26.1.2.
1. Install Fabric Loader and Fabric API for your Minecraft version (see table).
2. Install Mod Menu to open the mod's settings screen from the Mods menu.
3. Configure the Streamer.bot and TikFinity WebSocket addresses in
`config/stream-chat.json` or in the in-game settings.
`config/stream-chat/stream-chat.json` or in the in-game settings.
4. Start Streamer.bot/TikFinity and launch Minecraft. Enabled integrations
connect automatically; use the configured keybind or settings screen to
toggle them.
@@ -27,15 +31,93 @@ checking them for private information.
The mod is client-only. It does not send stream events or chat messages to the
Minecraft server.
## Development
## Configuration
Use Java 25 for the Minecraft 26.1.2 target. Build with Gradle:
All files live in `config/stream-chat/`:
```text
gradlew build
| File | Contents |
|------|----------|
| `stream-chat.json` | Platform and integration on/off, WebSocket URLs, event destinations (chat, action bar, toast, hidden), emote replacements |
| `twitch.yml`, `youtube.yml`, `tiktok.yml` | Message formatting for that platform |
Each platform file has a `prefix`, a fallback `format`, and an `events:` section
with the message format for every event. They are written with defaults the
first time the mod runs. Per-event formats are edited only in the file; the
settings menu edits the prefix and the chat-message format. For example, in
`tiktok.yml`:
```yaml
prefix: "&#FE2C55[TikTok] &r| "
events:
join: "&e@{username} joined"
like: "{prefix}{username} sent {data.likeCount} likes"
```
The Minecraft version, Fabric Loader, Fabric API, Mod Menu, Loom, and Java
versions are declared in `gradle.properties`. Select matching versions there
before building a different Minecraft target. The legacy 1.20.1 source set
was verified separately; other releases need their own compatibility checks.
Delete an `events:` line to restore that event's default. Every event is shown
in chat by default; change where each one goes (chat, action bar, toast, hidden)
in the settings menu.
### Placeholders
`{prefix}`, `{platform}`, `{event}`, `{username}`, `{message}`, and
`{data.<field>}`, which reads any field from the platform's event (for example
`{data.likeCount}`, `{data.repeatCount}`, `{data.bits}`). A missing field is blank.
### Color codes
Prefixes and formats support `&` color codes, so each platform can have its own color:
| Code | Effect |
|------|--------|
| `&0`-`&9`, `&a`-`&f` | Minecraft colors |
| `&#RRGGBB` | Any hex color, e.g. `&#9146FF` |
| `&l` `&o` `&n` `&m` `&k` | Bold, italic, underline, strikethrough, obfuscated |
| `&r` | Reset |
| `&&` | A literal `&` |
Text sent by viewers (usernames and messages) is never interpreted as color codes.
### Username color
Each platform file has a `username_color` setting that colors only the viewer's
name on that platform, leaving the message text alone. It takes the same codes as
above:
```yaml
username_color: "&#9146FF" # purple Twitch names
```
Leave it empty for no color. It applies to every event that uses `{username}`.
### Troubleshooting events
If events from a platform don't appear, set `"debugLogging": true` in
`stream-chat.json` and reload. Every raw message received from Streamer.bot or
TikFinity is then written to the game log (`logs/latest.log`), which shows the
exact event names and fields being sent. TikFinity's `config` message (its list
of configured actions) is not a viewer event and is ignored.
### Reloading
Changes to the files are not picked up while the game runs. Press **Reload
config** in the settings menu, or bind the **Reload Stream Chat Config** key in
Minecraft's controls, to re-read everything. Unsaved edits in the settings menu
are discarded by a reload, and saving the menu rewrites the files.
An existing `config/stream-chat.json` from an earlier version is migrated
automatically and kept as `stream-chat.json.bak`.
## Development
Run Gradle with a Java 25+ JVM (Java 17 targets are compiled with
`--release 17`). Build every supported version, one jar each, into `dist/`:
```text
powershell -File build-all.ps1
```
Each supported version is defined by `versions/<minecraft version>.properties`
(Minecraft, Fabric Loader, Fabric API, Mod Menu, and Java versions). To build a
single version, pass its properties as Gradle options, e.g.
`gradlew build -Pminecraft_version=1.20.1 -Ploader_version=0.15.11 -Pfabric_api_version=0.92.2+1.20.1 -Pmodmenu_version=7.2.2 -Pjava_version=17`.
To add a version, add a properties file; versions starting with `26.` use the
`src/versions/modern` sources, all others use `src/versions/legacy`.
+17
View File
@@ -0,0 +1,17 @@
# Builds one jar per supported Minecraft version (versions\*.properties) into dist\.
# Requires a Java 25+ JVM to run Gradle; Java 17 targets are compiled with --release 17.
$ErrorActionPreference = 'Stop'
Set-Location $PSScriptRoot
$dist = Join-Path $PSScriptRoot 'dist'
New-Item -ItemType Directory -Force $dist | Out-Null
Remove-Item "$dist\*.jar" -ErrorAction SilentlyContinue
foreach ($file in Get-ChildItem versions\*.properties) {
$props = @()
Get-Content $file | Where-Object { $_ -match '^\s*[^#\s].*=' } | ForEach-Object { $props += "-P$($_.Trim())" }
Write-Host "== Building Minecraft $($file.BaseName) =="
& .\gradlew.bat clean build @props
if ($LASTEXITCODE -ne 0) { throw "Build failed for $($file.BaseName)" }
Copy-Item build\libs\*-$($file.BaseName)-*.jar $dist -Exclude '*-sources.jar'
}
Write-Host "Jars written to $dist"
+1 -1
View File
@@ -9,6 +9,6 @@ loom_version=1.19.3
legacy_loom_version=1.14.10
java_version=25
mod_version=0.1.0
mod_version=0.1.5
maven_group=dev.streamchat
archives_base_name=stream-chat
@@ -0,0 +1,70 @@
package dev.streamchat;
import net.minecraft.ChatFormatting;
import net.minecraft.network.chat.Component;
import net.minecraft.network.chat.MutableComponent;
import net.minecraft.network.chat.Style;
import java.util.Locale;
/**
* Converts {@code &} color codes into a styled component: {@code &0-&f} colors, {@code &l &o &n &m &k}
* styles, {@code &r} reset, {@code &#RRGGBB} hex colors, and {@code &&} for a literal ampersand.
*/
public final class ColorText {
private ColorText() {
}
/** Escapes viewer-supplied text so it can never inject formatting. */
public static String escape(String value) {
return value.replace("\u00a7", "").replace("&", "&&");
}
public static Component parse(String text) {
MutableComponent result = Component.empty();
StringBuilder current = new StringBuilder();
Style style = Style.EMPTY;
for (int i = 0; i < text.length(); i++) {
char c = text.charAt(i);
if (c == '&' && i + 1 < text.length()) {
char next = text.charAt(i + 1);
if (next == '&') {
current.append('&');
i++;
continue;
}
if (next == '#' && isHex(text, i + 2)) {
flush(result, current, style);
style = style.withColor(Integer.parseInt(text.substring(i + 2, i + 8), 16));
i += 7;
continue;
}
ChatFormatting formatting = ChatFormatting.getByCode(Character.toLowerCase(next));
if (formatting != null) {
flush(result, current, style);
style = formatting == ChatFormatting.RESET ? Style.EMPTY : style.applyFormat(formatting);
i++;
continue;
}
}
current.append(c);
}
flush(result, current, style);
return result;
}
private static boolean isHex(String text, int start) {
if (start + 6 > text.length()) return false;
String digits = text.substring(start, start + 6).toLowerCase(Locale.ROOT);
for (int i = 0; i < digits.length(); i++) {
if (Character.digit(digits.charAt(i), 16) < 0) return false;
}
return true;
}
private static void flush(MutableComponent result, StringBuilder current, Style style) {
if (current.isEmpty()) return;
result.append(Component.literal(current.toString()).setStyle(style));
current.setLength(0);
}
}
@@ -0,0 +1,83 @@
package dev.streamchat;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/** Reads and writes the small, fixed-shape YAML file used for each platform's message formatting. */
public final class PlatformConfigFile {
public String prefix;
public String format;
public String usernameColor;
public final Map<String, String> events = new LinkedHashMap<>();
public static PlatformConfigFile read(Path path) throws IOException {
PlatformConfigFile file = new PlatformConfigFile();
boolean inEvents = false;
for (String line : Files.readAllLines(path, StandardCharsets.UTF_8)) {
String trimmed = line.strip();
if (trimmed.isEmpty() || trimmed.startsWith("#")) continue;
boolean indented = Character.isWhitespace(line.charAt(0));
int colon = trimmed.indexOf(':');
if (colon <= 0) continue;
String key = unquote(trimmed.substring(0, colon).strip());
String value = trimmed.substring(colon + 1).strip();
if (indented && inEvents) {
file.events.put(key, parseValue(value));
} else if (!indented) {
inEvents = key.equals("events");
if (key.equals("prefix")) file.prefix = parseValue(value);
else if (key.equals("format")) file.format = parseValue(value);
else if (key.equals("username_color")) file.usernameColor = parseValue(value);
}
}
return file;
}
public static void write(Path path, Platform platform, String prefix, String usernameColor, String format,
Map<String, String> events, List<String> eventNames) throws IOException {
StringBuilder out = new StringBuilder();
out.append("# ").append(platform.name()).append(" message formatting for Stream Chat.\n");
out.append("# Edit this file, then reload in-game; saving the in-game menu rewrites it.\n");
out.append("# Placeholders: {prefix} {platform} {event} {username} {message} {data.<field>}\n");
out.append("# {data.<field>} reads any field from the platform's event, e.g. {data.likeCount}.\n");
out.append("# Colors: &0-&f, &l bold, &o italic, &n underline, &m strikethrough, &r reset, &#RRGGBB hex.\n");
out.append("# Use && for a literal &. After editing, reload in-game from the settings menu or the reload keybind.\n\n");
out.append("# Text placed in front of messages ({prefix}). Example: \"&#9146FF[Twitch] &r| \"\n");
out.append("prefix: ").append(quote(prefix)).append("\n\n");
out.append("# Color applied to every {username} on this platform, e.g. \"&5\" or \"&#9146FF\". Empty = none.\n");
out.append("username_color: ").append(quote(usernameColor)).append("\n\n");
out.append("# Fallback format for events not listed below.\n");
out.append("format: ").append(quote(format)).append("\n\n");
out.append("# Format for each event. Delete a line to restore its default.\n");
out.append("# Available events: ").append(String.join(", ", eventNames)).append("\n");
out.append("# Example: join: \"&e@{username} joined\"\n");
out.append("events:\n");
for (Map.Entry<String, String> entry : events.entrySet()) {
out.append(" ").append(entry.getKey()).append(": ").append(quote(entry.getValue())).append("\n");
}
Files.writeString(path, out.toString(), StandardCharsets.UTF_8);
}
private static String quote(String value) {
return "\"" + value.replace("\\", "\\\\").replace("\"", "\\\"") + "\"";
}
private static String parseValue(String raw) {
if (raw.length() >= 2 && raw.startsWith("\"") && raw.endsWith("\"")) {
return raw.substring(1, raw.length() - 1).replace("\\\"", "\"").replace("\\\\", "\\");
}
return unquote(raw);
}
private static String unquote(String raw) {
if (raw.length() >= 2 && raw.startsWith("'") && raw.endsWith("'")) {
return raw.substring(1, raw.length() - 1).replace("''", "'");
}
return raw;
}
}
@@ -2,33 +2,89 @@ package dev.streamchat;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParseException;
import com.google.gson.JsonParser;
import net.fabricmc.loader.api.FabricLoader;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.util.EnumMap;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.logging.Level;
import java.util.logging.Logger;
public final class StreamChatConfig {
private static final Logger LOGGER = Logger.getLogger("StreamChat");
private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create();
private static final Path PATH = FabricLoader.getInstance().getConfigDir().resolve("stream-chat.json");
private static final Path DIR = FabricLoader.getInstance().getConfigDir().resolve("stream-chat");
private static final Path PATH = DIR.resolve("stream-chat.json");
private static final Path LEGACY_PATH = FabricLoader.getInstance().getConfigDir().resolve("stream-chat.json");
private static final Map<Platform, Map<String, String>> DEFAULT_FORMATS = defaultFormats();
private static final Pattern PLACEHOLDER =
Pattern.compile("\\{(prefix|platform|event|username|message|data\\.[A-Za-z0-9_]+)}");
private static final Map<Platform, List<String>> EVENT_NAMES = eventNames();
private static Map<Platform, List<String>> eventNames() {
Map<Platform, List<String>> names = new EnumMap<>(Platform.class);
DEFAULT_FORMATS.forEach((platform, formats) -> names.put(platform, List.copyOf(formats.keySet())));
return names;
}
private static Map<Platform, Map<String, String>> defaultFormats() {
Map<Platform, Map<String, String>> all = new EnumMap<>(Platform.class);
Map<String, String> twitch = new LinkedHashMap<>();
twitch.put("ChatMessage", "{prefix}{username} > {message}");
twitch.put("Follow", "{prefix}{username} followed");
twitch.put("Sub", "{prefix}{username} subscribed");
twitch.put("ReSub", "{prefix}{username} resubscribed: {message}");
twitch.put("GiftSub", "{prefix}{username} gifted a sub");
twitch.put("Cheer", "{prefix}{username} cheered {data.bits} bits: {message}");
twitch.put("Raid", "{prefix}{username} raided with {data.viewers} viewers");
all.put(Platform.TWITCH, twitch);
Map<String, String> youtube = new LinkedHashMap<>();
youtube.put("Message", "{prefix}{username} > {message}");
youtube.put("SuperChat", "{prefix}{username} sent a Super Chat {data.amount}: {message}");
youtube.put("SuperSticker", "{prefix}{username} sent a Super Sticker");
youtube.put("MemberMilestone", "{prefix}{username} member milestone: {message}");
youtube.put("NewSponsor", "{prefix}{username} became a member");
youtube.put("GiftMembershipReceived", "{prefix}{username} received a gifted membership");
all.put(Platform.YOUTUBE, youtube);
Map<String, String> tiktok = new LinkedHashMap<>();
tiktok.put("chat", "{prefix}{username} > {message}");
tiktok.put("gift", "{prefix}{username} sent {message} x{data.repeatCount}");
tiktok.put("share", "{prefix}{username} shared the stream");
tiktok.put("follow", "{prefix}{username} followed");
tiktok.put("like", "{prefix}{username} sent {data.likeCount} likes ({data.totalLikeCount} total)");
tiktok.put("join", "{prefix}{username} joined");
tiktok.put("subscribe", "{prefix}{username} subscribed ({data.subMonth} months)");
all.put(Platform.TIKTOK, tiktok);
return all;
}
public boolean twitchEnabled = true;
public boolean youtubeEnabled = true;
public boolean tiktokEnabled = true;
public boolean streamerBotEnabled = true;
public boolean tikFinityEnabled = true;
public boolean debugLogging = false;
public String streamerBotUrl = "ws://127.0.0.1:8080/";
public String streamerBotPassword = "";
public String tikFinityUrl = "ws://127.0.0.1:21213/";
public Map<String, String> prefixes = new HashMap<>();
public Map<String, String> templates = new HashMap<>();
// Formatting lives in per-platform YAML files, not in stream-chat.json.
public transient Map<String, String> prefixes = new HashMap<>();
public transient Map<String, String> templates = new HashMap<>();
public transient Map<String, String> usernameColors = new HashMap<>();
public transient Map<String, Map<String, String>> eventFormats = new HashMap<>();
public Map<String, DisplayTarget> routes = new HashMap<>();
public Map<String, String> emoteReplacements = new HashMap<>();
@@ -39,41 +95,93 @@ public final class StreamChatConfig {
templates.put("TWITCH", "{prefix}{username} > {message}");
templates.put("YOUTUBE", "{prefix}{username} > {message}");
templates.put("TIKTOK", "{prefix}{username} > {message}");
setDefaultRoute("TWITCH", "ChatMessage", DisplayTarget.CHAT);
setDefaultRoute("YOUTUBE", "Message", DisplayTarget.CHAT);
setDefaultRoute("TIKTOK", "chat", DisplayTarget.CHAT);
DEFAULT_FORMATS.forEach((platform, formats) -> {
eventFormats.put(platform.name(), new LinkedHashMap<>(formats));
formats.keySet().forEach(event -> setDefaultRoute(platform.name(), event, DisplayTarget.CHAT));
});
}
public static StreamChatConfig load() {
if (!Files.exists(PATH)) {
boolean migrating = !Files.exists(PATH) && Files.exists(LEGACY_PATH);
Path source = migrating ? LEGACY_PATH : PATH;
if (!Files.exists(source)) {
StreamChatConfig config = new StreamChatConfig();
config.save();
return config;
}
try (var reader = Files.newBufferedReader(PATH)) {
StreamChatConfig config = GSON.fromJson(reader, StreamChatConfig.class);
try {
String json = Files.readString(source);
StreamChatConfig config = GSON.fromJson(json, StreamChatConfig.class);
if (config == null) {
throw new JsonParseException("Configuration file was empty.");
}
config.applyMissingDefaults();
if (migrating) {
config.importLegacyFormatting(JsonParser.parseString(json).getAsJsonObject());
}
config.loadPlatformFiles();
if (migrating) {
config.save();
Files.move(LEGACY_PATH, LEGACY_PATH.resolveSibling("stream-chat.json.bak"),
StandardCopyOption.REPLACE_EXISTING);
}
return config;
} catch (IOException | JsonParseException exception) {
LOGGER.log(Level.SEVERE, "Could not read " + PATH + "; using defaults.", exception);
} catch (IOException | JsonParseException | IllegalStateException exception) {
LOGGER.log(Level.SEVERE, "Could not read " + source + "; using defaults.", exception);
return new StreamChatConfig();
}
}
public void save() {
try {
Files.createDirectories(PATH.getParent());
Files.createDirectories(DIR);
try (var writer = Files.newBufferedWriter(PATH)) {
GSON.toJson(this, writer);
}
for (Platform platform : Platform.values()) {
String name = platform.name();
PlatformConfigFile.write(platformFile(platform), platform, prefixes.get(name), usernameColors.getOrDefault(name, ""), templates.get(name),
eventFormats.getOrDefault(name, Map.of()), EVENT_NAMES.get(platform));
}
} catch (IOException exception) {
LOGGER.log(Level.SEVERE, "Could not save " + PATH, exception);
LOGGER.log(Level.SEVERE, "Could not save configuration in " + DIR, exception);
}
}
private static Path platformFile(Platform platform) {
return DIR.resolve(platform.name().toLowerCase(Locale.ROOT) + ".yml");
}
private void loadPlatformFiles() {
for (Platform platform : Platform.values()) {
Path path = platformFile(platform);
if (!Files.exists(path)) continue;
try {
PlatformConfigFile file = PlatformConfigFile.read(path);
String name = platform.name();
if (file.prefix != null) prefixes.put(name, file.prefix);
if (file.usernameColor != null) usernameColors.put(name, file.usernameColor);
if (file.format != null && !file.format.isBlank()) templates.put(name, file.format);
eventFormats.computeIfAbsent(name, key -> new LinkedHashMap<>()).putAll(file.events);
} catch (IOException exception) {
LOGGER.log(Level.SEVERE, "Could not read " + path + "; using defaults.", exception);
}
}
save();
}
private void importLegacyFormatting(JsonObject root) {
importLegacyMap(root, "prefixes", prefixes);
importLegacyMap(root, "templates", templates);
}
private static void importLegacyMap(JsonObject root, String key, Map<String, String> target) {
if (!root.has(key) || !root.get(key).isJsonObject()) return;
root.getAsJsonObject(key).entrySet().forEach(entry -> {
if (entry.getValue().isJsonPrimitive()) target.put(entry.getKey(), entry.getValue().getAsString());
});
}
public boolean isEnabled(Platform platform) {
return switch (platform) {
case TWITCH -> twitchEnabled;
@@ -99,14 +207,87 @@ public final class StreamChatConfig {
}
public String format(StreamEvent event) {
String template = templates.getOrDefault(
event.platform().name(), "{prefix}{username} > {message}");
return template
.replace("{prefix}", getPrefix(event.platform()))
.replace("{platform}", event.platform().name())
.replace("{event}", event.type())
.replace("{username}", event.username())
.replace("{message}", replaceEmotes(event.message()));
String platformName = event.platform().name();
String template = eventFormats.getOrDefault(platformName, Map.of()).get(event.type());
if (template == null || template.isBlank()) {
template = templates.getOrDefault(platformName, "{prefix}{username} > {message}");
}
JsonObject data = dataOf(event.rawJson());
Matcher matcher = PLACEHOLDER.matcher(template);
StringBuilder result = new StringBuilder();
while (matcher.find()) {
String key = matcher.group(1);
String value = switch (key) {
case "prefix" -> getPrefix(event.platform());
case "platform" -> platformName;
case "event" -> ColorText.escape(event.type());
case "username" -> colorUsername(platformName, event.username());
case "message" -> ColorText.escape(replaceEmotes(event.message()));
default -> ColorText.escape(findField(data, key.substring("data.".length())));
};
matcher.appendReplacement(result, Matcher.quoteReplacement(value));
}
matcher.appendTail(result);
return result.toString();
}
private String colorUsername(String platformName, String username) {
String color = usernameColors.getOrDefault(platformName, "");
String name = ColorText.escape(username);
return color.isBlank() ? name : color + name + "&r";
}
private static JsonObject dataOf(String rawJson) {
try {
JsonObject root = JsonParser.parseString(rawJson).getAsJsonObject();
return root.has("data") && root.get("data").isJsonObject() ? root.getAsJsonObject("data") : new JsonObject();
} catch (RuntimeException exception) {
return new JsonObject();
}
}
private static String findField(JsonObject object, String key) {
JsonElement direct = object.get(key);
if (direct != null && direct.isJsonPrimitive()) return direct.getAsString();
for (Map.Entry<String, JsonElement> entry : object.entrySet()) {
if (entry.getValue().isJsonObject()) {
String nested = findField(entry.getValue().getAsJsonObject(), key);
if (!nested.isEmpty()) return nested;
}
}
return "";
}
/** The format shown in the settings menu: the platform's regular chat-message event. */
public String getChatFormat(Platform platform) {
String event = EVENT_NAMES.get(platform).get(0);
return eventFormats.getOrDefault(platform.name(), Map.of())
.getOrDefault(event, templates.get(platform.name()));
}
public void setChatFormat(Platform platform, String format) {
eventFormats.computeIfAbsent(platform.name(), key -> new LinkedHashMap<>())
.put(EVENT_NAMES.get(platform).get(0), format);
}
/** Re-reads every config file from disk into this instance. */
public synchronized void reload() {
StreamChatConfig fresh = load();
twitchEnabled = fresh.twitchEnabled;
youtubeEnabled = fresh.youtubeEnabled;
tiktokEnabled = fresh.tiktokEnabled;
streamerBotEnabled = fresh.streamerBotEnabled;
tikFinityEnabled = fresh.tikFinityEnabled;
debugLogging = fresh.debugLogging;
streamerBotUrl = fresh.streamerBotUrl;
streamerBotPassword = fresh.streamerBotPassword;
tikFinityUrl = fresh.tikFinityUrl;
prefixes = fresh.prefixes;
templates = fresh.templates;
usernameColors = fresh.usernameColors;
eventFormats = fresh.eventFormats;
routes = fresh.routes;
emoteReplacements = fresh.emoteReplacements;
}
public String replaceEmotes(String message) {
@@ -14,11 +14,9 @@ public final class StreamEventParser {
JsonObject root = JsonParser.parseString(text).getAsJsonObject();
String type = string(root, "event");
JsonObject data = object(root, "data");
String username = firstString(data, "uniqueId", "nickname", "username", "userName", "sender");
String message = firstString(data, "comment", "message", "giftName", "shareType", "label");
if (message.isBlank()) {
message = summarize(data);
}
String username = findUsername(data, "username", "uniqueId", "nickname", "userName", "displayName", "name");
String message = findString(data, "comment", "message", "giftName", "shareType", "label");
if ("member".equals(type)) type = "join";
return new StreamEvent(Platform.TIKTOK, type, username, message, text);
}
@@ -29,14 +27,31 @@ public final class StreamEventParser {
String source = string(event, "source").toLowerCase(Locale.ROOT);
Platform platform = source.contains("youtube") ? Platform.YOUTUBE : Platform.TWITCH;
String type = string(event, "type");
String username = firstString(data, "username", "userName", "displayName", "user");
String message = firstString(data, "message", "text", "comment", "giftName");
if (message.isBlank()) {
message = summarize(data);
}
String username = findUsername(data, "displayName", "username", "userName", "login", "name");
String message = findString(data, "message", "text", "comment", "giftName");
return new StreamEvent(platform, type, username, message, text);
}
private static final String[] USER_CONTAINERS = {"message", "user", "chatter", "author", "sender", "viewer"};
// Platforms nest the sender differently (e.g. Streamer.bot puts it in data.message or data.user).
private static String findUsername(JsonObject data, String... keys) {
String value = firstString(data, keys);
if (!value.isBlank()) return value;
for (String container : USER_CONTAINERS) {
value = firstString(object(data, container), keys);
if (!value.isBlank()) return value;
}
String plain = firstString(data, "user", "sender", "author");
return plain;
}
private static String findString(JsonObject data, String... keys) {
String value = firstString(data, keys);
if (!value.isBlank()) return value;
return firstString(object(data, "message"), keys);
}
private static JsonObject object(JsonObject object, String key) {
JsonElement value = object.get(key);
return value != null && value.isJsonObject() ? value.getAsJsonObject() : new JsonObject();
@@ -56,7 +71,4 @@ public final class StreamEventParser {
? "" : value.getAsString();
}
private static String summarize(JsonObject data) {
return data.toString();
}
}
@@ -1,4 +1,5 @@
{
"key.streamchat.toggle_connections": "Toggle Stream Chat Connections",
"key.streamchat.reload_config": "Reload Stream Chat Config",
"category.streamchat": "Stream Chat"
}
@@ -0,0 +1,27 @@
package dev.streamchat;
import net.minecraft.network.chat.Component;
import net.minecraft.network.chat.TextColor;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
class ColorTextTest {
@Test
void appliesLegacyAndHexColors() {
Component component = ColorText.parse("&c[Twitch] &#9146FFhi");
assertEquals("[Twitch] hi", component.getString());
assertEquals(TextColor.fromRgb(0xFF5555), component.getSiblings().get(0).getStyle().getColor());
assertEquals(TextColor.fromRgb(0x9146FF), component.getSiblings().get(1).getStyle().getColor());
}
@Test
void viewerTextCannotInjectFormatting() {
Component component = ColorText.parse("&a" + ColorText.escape("&4evil\u00a7k"));
assertEquals("&4evilk", component.getString());
assertNotNull(component.getSiblings().get(0).getStyle().getColor());
}
}
@@ -0,0 +1,47 @@
package dev.streamchat;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.nio.file.Path;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
class PlatformConfigFileTest {
@Test
void roundTripsFormatsAndEventOverrides(@TempDir Path dir) throws Exception {
Path file = dir.resolve("tiktok.yml");
PlatformConfigFile.write(file, Platform.TIKTOK, "[TikTok] | ", "&5", "{prefix}{username} > {message}",
Map.of("join", "@{username} \"joined\""), List.of("chat", "join"));
PlatformConfigFile read = PlatformConfigFile.read(file);
assertEquals("[TikTok] | ", read.prefix);
assertEquals("&5", read.usernameColor);
assertEquals("{prefix}{username} > {message}", read.format);
assertEquals("@{username} \"joined\"", read.events.get("join"));
}
@Test
void ignoresCommentedExampleAndReadsHandEdits(@TempDir Path dir) throws Exception {
Path file = dir.resolve("tiktok.yml");
java.nio.file.Files.writeString(file, """
# comment
prefix: '[TT] '
format: {prefix}{username}: {message}
events:
# join: "ignored"
join: "@{username} joined"
""");
PlatformConfigFile read = PlatformConfigFile.read(file);
assertEquals("[TT] ", read.prefix);
assertEquals("{prefix}{username}: {message}", read.format);
assertEquals(Map.of("join", "@{username} joined"), read.events);
assertTrue(read.events.size() == 1);
}
}
@@ -24,6 +24,38 @@ class StreamEventParserTest {
assertEquals("Hello!", event.message());
}
@Test
void parsesStreamerBotTwitchNestedMessage() {
StreamEvent event = StreamerBotNested.parse();
assertEquals(Platform.TWITCH, event.platform());
assertEquals("Viewer Three", event.username());
assertEquals("Hello Twitch", event.message());
}
private static final class StreamerBotNested {
static StreamEvent parse() {
return StreamEventParser.parseStreamerBot("""
{
"event": { "source": "Twitch", "type": "ChatMessage" },
"data": { "message": { "username": "viewer_three", "displayName": "Viewer Three",
"message": "Hello Twitch" } }
}
""");
}
}
@Test
void parsesTikFinityJoin() {
StreamEvent event = StreamEventParser.parseTikFinity("""
{ "event": "join", "data": { "userId": "1", "username": "joiner", "nickname": "Joiner" } }
""");
assertEquals("join", event.type());
assertEquals("joiner", event.username());
assertEquals("", event.message());
}
@Test
void parsesStreamerBotYouTubeEventEnvelope() {
StreamEvent event = StreamEventParser.parseStreamerBot("""
@@ -173,9 +173,11 @@ public final class ConnectionManager {
try {
JsonObject root = JsonParser.parseString(raw).getAsJsonObject();
if (socketPlatform == Platform.TWITCH && handleStreamerBotControl(root)) return;
if (config.debugLogging) LOGGER.info("Received from " + socketPlatform + ": " + raw);
StreamEvent event = socketPlatform == Platform.TIKTOK
? StreamEventParser.parseTikFinity(raw)
: StreamEventParser.parseStreamerBot(raw);
if (event.platform() == Platform.TIKTOK && "config".equals(event.type())) return;
synchronized (this) {
lastEvents.put(event.platform(), Instant.now());
statuses.put(event.platform(), "Connected");
@@ -290,7 +292,7 @@ public final class ConnectionManager {
private boolean isKnownEvent(StreamEvent event) {
if (event.platform() == Platform.TIKTOK) {
return switch (event.type()) {
case "chat", "gift", "share", "follow", "like", "roomUser", "subscribe" -> true;
case "chat", "gift", "share", "follow", "like", "join", "subscribe" -> true;
default -> false;
};
}
@@ -306,7 +308,7 @@ public final class ConnectionManager {
private void display(StreamEvent event, DisplayTarget target) {
String text = config.format(event);
Component component = Component.literal(text);
Component component = ColorText.parse(text);
switch (target) {
case CHAT -> {
if (client.gui != null) client.gui.getChat().addMessage(component);
@@ -318,7 +320,7 @@ public final class ConnectionManager {
client.getToasts(),
SystemToast.SystemToastIds.TUTORIAL_HINT,
Component.literal(event.platform() + " " + event.type()),
Component.literal(text));
component);
case HIDDEN -> {
}
}
@@ -11,6 +11,9 @@ public final class StreamChatClient implements ClientModInitializer {
private static final KeyMapping TOGGLE_CONNECTIONS = KeyBindingHelper.registerKeyBinding(
new KeyMapping("key.streamchat.toggle_connections", GLFW.GLFW_KEY_UNKNOWN, "category.streamchat"));
private static final KeyMapping RELOAD_CONFIG = KeyBindingHelper.registerKeyBinding(
new KeyMapping("key.streamchat.reload_config", GLFW.GLFW_KEY_UNKNOWN, "category.streamchat"));
public static StreamChatConfig CONFIG;
public static ConnectionManager CONNECTIONS;
@@ -23,6 +26,10 @@ public final class StreamChatClient implements ClientModInitializer {
while (TOGGLE_CONNECTIONS.consumeClick()) {
CONNECTIONS.toggleConnections();
}
while (RELOAD_CONFIG.consumeClick()) {
CONFIG.reload();
CONNECTIONS.configurationChanged();
}
});
}
}
@@ -1,11 +1,13 @@
package dev.streamchat;
import net.minecraft.client.gui.GuiGraphics;
import net.minecraft.client.gui.components.AbstractWidget;
import net.minecraft.client.gui.components.Button;
import net.minecraft.client.gui.components.EditBox;
import net.minecraft.client.gui.screens.Screen;
import net.minecraft.network.chat.Component;
import java.util.ArrayList;
import java.util.List;
public final class StreamChatScreen extends Screen {
@@ -14,7 +16,7 @@ public final class StreamChatScreen extends Screen {
private static final List<String> YOUTUBE_EVENTS =
List.of("Message", "SuperChat", "SuperSticker", "MemberMilestone", "NewSponsor", "GiftMembershipReceived");
private static final List<String> TIKTOK_EVENTS =
List.of("chat", "gift", "share", "follow", "like", "roomUser", "subscribe");
List.of("chat", "gift", "share", "follow", "like", "join", "subscribe");
private final Screen parent;
private final StreamChatConfig config;
@@ -25,6 +27,10 @@ public final class StreamChatScreen extends Screen {
private EditBox tikFinityUrl;
private EditBox prefix;
private EditBox template;
private final List<AbstractWidget> scrollable = new ArrayList<>();
private final List<Integer> baseY = new ArrayList<>();
private int scroll;
private int contentEnd;
public StreamChatScreen(Screen parent, StreamChatConfig config, ConnectionManager connections) {
super(Component.literal("Stream Chat Settings"));
@@ -90,17 +96,19 @@ public final class StreamChatScreen extends Screen {
prefix.setValue(config.getPrefix(selectedPlatform));
prefix.setMaxLength(128);
addRenderableWidget(prefix);
template = new EditBox(font, left, 270, rowWidth, 20, Component.literal("Message format"));
template.setValue(config.templates.getOrDefault(selectedPlatform.name(),
"{prefix}{username} > {message}"));
template = new EditBox(font, left, 270, rowWidth, 20, Component.literal("Chat message format"));
template.setValue(config.getChatFormat(selectedPlatform));
template.setMaxLength(256);
addRenderableWidget(template);
addRouteButtons(left);
captureScrollable();
addRenderableWidget(Button.builder(Component.literal("Toggle connections"), button -> connections.toggleConnections())
.bounds(left, height - 32, 180, 20).build());
.bounds(left, height - 32, 120, 20).build());
addRenderableWidget(Button.builder(Component.literal("Reload config"), button -> reloadConfig())
.bounds(left + 130, height - 32, 120, 20).build());
addRenderableWidget(Button.builder(Component.literal("Done"), button -> onClose())
.bounds(left + 200, height - 32, 180, 20).build());
.bounds(left + 260, height - 32, 120, 20).build());
}
@Override
@@ -108,22 +116,78 @@ public final class StreamChatScreen extends Screen {
renderBackground(graphics);
graphics.drawCenteredString(font, title, width / 2, 8, 0xFFFFFF);
int left = width / 2 - 190;
graphics.drawString(font, "Streamer.bot / TikFinity WebSocket settings", left, 60, 0xA0A0A0);
graphics.drawString(font, "Connection status: " + connections.status(selectedPlatform), left, 188, 0xFFFFFF);
graphics.drawString(font, "Last event: " + connections.lastEvent(selectedPlatform), left + 180, 188, 0xA0A0A0);
graphics.drawString(font, "Prefix", left, 220, 0xA0A0A0);
graphics.drawString(font, "Format: {prefix}, {platform}, {event}, {username}, {message}", left, 256, 0xA0A0A0);
graphics.drawString(font, "Event destination (click to cycle)", left, 296, 0xA0A0A0);
label(graphics, "Streamer.bot / TikFinity WebSocket settings", left, 60, 0xA0A0A0);
label(graphics, "Connection status: " + connections.status(selectedPlatform), left, 188, 0xFFFFFF);
label(graphics, "Last event: " + connections.lastEvent(selectedPlatform), left + 180, 188, 0xA0A0A0);
label(graphics, "Prefix", left, 220, 0xA0A0A0);
label(graphics, "Chat format ({prefix} {username} {message}; &c colors; other events: platform .yml)", left, 256, 0xA0A0A0);
label(graphics, "Event destination (click to cycle)", left, 296, 0xA0A0A0);
super.render(graphics, mouseX, mouseY, delta);
}
private void label(GuiGraphics graphics, String text, int x, int baseY, int color) {
int y = baseY - scroll;
if (y >= viewTop() && y + 9 <= viewBottom()) {
graphics.drawString(font, text, x, y, color);
}
}
private int viewTop() {
return 24;
}
private int viewBottom() {
return height - 40;
}
private int maxScroll() {
return Math.max(0, contentEnd + 8 - viewBottom());
}
private void captureScrollable() {
scrollable.clear();
baseY.clear();
for (var child : children()) {
if (child instanceof AbstractWidget widget) {
scrollable.add(widget);
baseY.add(widget.getY());
}
}
scroll = Math.min(scroll, maxScroll());
applyScroll();
}
private void applyScroll() {
for (int i = 0; i < scrollable.size(); i++) {
AbstractWidget widget = scrollable.get(i);
int y = baseY.get(i) - scroll;
widget.setY(y);
boolean inView = y >= viewTop() && y + widget.getHeight() <= viewBottom();
widget.visible = inView;
widget.active = inView;
}
}
private boolean scrollBy(double amount) {
int next = (int) Math.max(0, Math.min(maxScroll(), scroll - amount * 20));
if (next == scroll) return false;
scroll = next;
applyScroll();
return true;
}
@Override
public boolean mouseScrolled(double mouseX, double mouseY, double delta) {
return scrollBy(delta) || super.mouseScrolled(mouseX, mouseY, delta);
}
@Override
public void onClose() {
config.streamerBotUrl = streamerBotUrl.getValue().trim();
config.streamerBotPassword = streamerBotPassword.getValue();
config.tikFinityUrl = tikFinityUrl.getValue().trim();
config.prefixes.put(selectedPlatform.name(), prefix.getValue());
config.templates.put(selectedPlatform.name(), template.getValue());
config.setChatFormat(selectedPlatform, template.getValue());
config.save();
connections.configurationChanged();
minecraft.setScreen(parent);
@@ -134,11 +198,16 @@ public final class StreamChatScreen extends Screen {
config.streamerBotPassword = streamerBotPassword.getValue();
config.tikFinityUrl = tikFinityUrl.getValue().trim();
config.prefixes.put(selectedPlatform.name(), prefix.getValue());
config.templates.put(selectedPlatform.name(), template.getValue());
config.setChatFormat(selectedPlatform, template.getValue());
selectedPlatform = platform;
prefix.setValue(config.getPrefix(platform));
template.setValue(config.templates.getOrDefault(platform.name(),
"{prefix}{username} > {message}"));
template.setValue(config.getChatFormat(platform));
rebuildRoutes();
}
private void reloadConfig() {
config.reload();
connections.configurationChanged();
rebuildRoutes();
}
@@ -153,6 +222,7 @@ public final class StreamChatScreen extends Screen {
case YOUTUBE -> YOUTUBE_EVENTS;
case TIKTOK -> TIKTOK_EVENTS;
};
contentEnd = 320 + (events.size() + 1) / 2 * 24;
for (int i = 0; i < events.size(); i++) {
String eventType = events.get(i);
int x = left + (i % 2) * 195;
@@ -169,9 +169,11 @@ public final class ConnectionManager {
try {
JsonObject root = JsonParser.parseString(raw).getAsJsonObject();
if (socketPlatform == Platform.TWITCH && handleStreamerBotControl(root)) return;
if (config.debugLogging) LOGGER.info("Received from " + socketPlatform + ": " + raw);
StreamEvent event = socketPlatform == Platform.TIKTOK
? StreamEventParser.parseTikFinity(raw)
: StreamEventParser.parseStreamerBot(raw);
if (event.platform() == Platform.TIKTOK && "config".equals(event.type())) return;
synchronized (this) {
lastEvents.put(event.platform(), Instant.now());
statuses.put(event.platform(), "Connected");
@@ -285,7 +287,7 @@ public final class ConnectionManager {
private boolean isKnownEvent(StreamEvent event) {
if (event.platform() == Platform.TIKTOK) {
return switch (event.type()) {
case "chat", "gift", "share", "follow", "like", "roomUser", "subscribe" -> true;
case "chat", "gift", "share", "follow", "like", "join", "subscribe" -> true;
default -> false;
};
}
@@ -300,7 +302,7 @@ public final class ConnectionManager {
}
private void display(StreamEvent event, DisplayTarget target) {
Component message = Component.literal(config.format(event));
Component message = ColorText.parse(config.format(event));
switch (target) {
case CHAT -> client.gui.getChat().addClientSystemMessage(message);
case ACTION_BAR -> client.gui.setOverlayMessage(message, false);
@@ -14,6 +14,9 @@ public final class StreamChatClient implements ClientModInitializer {
private static final KeyMapping TOGGLE_CONNECTIONS = KeyMappingHelper.registerKeyMapping(
new KeyMapping("key.streamchat.toggle_connections", GLFW.GLFW_KEY_UNKNOWN, CATEGORY));
private static final KeyMapping RELOAD_CONFIG = KeyMappingHelper.registerKeyMapping(
new KeyMapping("key.streamchat.reload_config", GLFW.GLFW_KEY_UNKNOWN, CATEGORY));
public static StreamChatConfig CONFIG;
public static ConnectionManager CONNECTIONS;
@@ -26,6 +29,10 @@ public final class StreamChatClient implements ClientModInitializer {
while (TOGGLE_CONNECTIONS.consumeClick()) {
CONNECTIONS.toggleConnections();
}
while (RELOAD_CONFIG.consumeClick()) {
CONFIG.reload();
CONNECTIONS.configurationChanged();
}
});
}
}
@@ -2,11 +2,13 @@ package dev.streamchat;
import com.terraformersmc.modmenu.api.ConfigScreenFactory;
import net.minecraft.client.gui.GuiGraphicsExtractor;
import net.minecraft.client.gui.components.AbstractWidget;
import net.minecraft.client.gui.components.Button;
import net.minecraft.client.gui.components.EditBox;
import net.minecraft.client.gui.screens.Screen;
import net.minecraft.network.chat.Component;
import java.util.ArrayList;
import java.util.List;
public final class StreamChatScreen extends Screen {
@@ -15,7 +17,7 @@ public final class StreamChatScreen extends Screen {
private static final List<String> YOUTUBE_EVENTS =
List.of("Message", "SuperChat", "SuperSticker", "MemberMilestone", "NewSponsor", "GiftMembershipReceived");
private static final List<String> TIKTOK_EVENTS =
List.of("chat", "gift", "share", "follow", "like", "roomUser", "subscribe");
List.of("chat", "gift", "share", "follow", "like", "join", "subscribe");
private final Screen parent;
private final StreamChatConfig config;
@@ -26,6 +28,10 @@ public final class StreamChatScreen extends Screen {
private EditBox tikFinityUrl;
private EditBox prefix;
private EditBox template;
private final List<AbstractWidget> scrollable = new ArrayList<>();
private final List<Integer> baseY = new ArrayList<>();
private int scroll;
private int contentEnd;
public StreamChatScreen(Screen parent, StreamChatConfig config, ConnectionManager connections) {
super(Component.literal("Stream Chat Settings"));
@@ -91,17 +97,19 @@ public final class StreamChatScreen extends Screen {
prefix.setValue(config.getPrefix(selectedPlatform));
prefix.setMaxLength(128);
addRenderableWidget(prefix);
template = new EditBox(font, left, 270, rowWidth, 20, Component.literal("Message format"));
template.setValue(config.templates.getOrDefault(selectedPlatform.name(),
"{prefix}{username} > {message}"));
template = new EditBox(font, left, 270, rowWidth, 20, Component.literal("Chat message format"));
template.setValue(config.getChatFormat(selectedPlatform));
template.setMaxLength(256);
addRenderableWidget(template);
addRouteButtons(left);
captureScrollable();
addRenderableWidget(Button.builder(Component.literal("Toggle connections"), button -> connections.toggleConnections())
.bounds(left, height - 32, 180, 20).build());
.bounds(left, height - 32, 120, 20).build());
addRenderableWidget(Button.builder(Component.literal("Reload config"), button -> reloadConfig())
.bounds(left + 130, height - 32, 120, 20).build());
addRenderableWidget(Button.builder(Component.literal("Done"), button -> onClose())
.bounds(left + 200, height - 32, 180, 20).build());
.bounds(left + 260, height - 32, 120, 20).build());
}
@Override
@@ -109,22 +117,78 @@ public final class StreamChatScreen extends Screen {
extractMenuBackground(graphics);
graphics.centeredText(font, title, width / 2, 8, 0xFFFFFF);
int left = width / 2 - 190;
graphics.text(font, "Streamer.bot / TikFinity WebSocket settings", left, 60, 0xA0A0A0);
graphics.text(font, "Connection status: " + connections.status(selectedPlatform), left, 188, 0xFFFFFF);
graphics.text(font, "Last event: " + connections.lastEvent(selectedPlatform), left + 180, 188, 0xA0A0A0);
graphics.text(font, "Prefix", left, 220, 0xA0A0A0);
graphics.text(font, "Format: {prefix}, {platform}, {event}, {username}, {message}", left, 256, 0xA0A0A0);
graphics.text(font, "Event destination (click to cycle)", left, 296, 0xA0A0A0);
label(graphics, "Streamer.bot / TikFinity WebSocket settings", left, 60, 0xA0A0A0);
label(graphics, "Connection status: " + connections.status(selectedPlatform), left, 188, 0xFFFFFF);
label(graphics, "Last event: " + connections.lastEvent(selectedPlatform), left + 180, 188, 0xA0A0A0);
label(graphics, "Prefix", left, 220, 0xA0A0A0);
label(graphics, "Chat format ({prefix} {username} {message}; &c colors; other events: platform .yml)", left, 256, 0xA0A0A0);
label(graphics, "Event destination (click to cycle)", left, 296, 0xA0A0A0);
super.extractRenderState(graphics, mouseX, mouseY, delta);
}
private void label(GuiGraphicsExtractor graphics, String text, int x, int baseY, int color) {
int y = baseY - scroll;
if (y >= viewTop() && y + 9 <= viewBottom()) {
graphics.text(font, text, x, y, color);
}
}
private int viewTop() {
return 24;
}
private int viewBottom() {
return height - 40;
}
private int maxScroll() {
return Math.max(0, contentEnd + 8 - viewBottom());
}
private void captureScrollable() {
scrollable.clear();
baseY.clear();
for (var child : children()) {
if (child instanceof AbstractWidget widget) {
scrollable.add(widget);
baseY.add(widget.getY());
}
}
scroll = Math.min(scroll, maxScroll());
applyScroll();
}
private void applyScroll() {
for (int i = 0; i < scrollable.size(); i++) {
AbstractWidget widget = scrollable.get(i);
int y = baseY.get(i) - scroll;
widget.setY(y);
boolean inView = y >= viewTop() && y + widget.getHeight() <= viewBottom();
widget.visible = inView;
widget.active = inView;
}
}
private boolean scrollBy(double amount) {
int next = (int) Math.max(0, Math.min(maxScroll(), scroll - amount * 20));
if (next == scroll) return false;
scroll = next;
applyScroll();
return true;
}
@Override
public boolean mouseScrolled(double mouseX, double mouseY, double scrollX, double scrollY) {
return scrollBy(scrollY) || super.mouseScrolled(mouseX, mouseY, scrollX, scrollY);
}
@Override
public void onClose() {
config.streamerBotUrl = streamerBotUrl.getValue().trim();
config.streamerBotPassword = streamerBotPassword.getValue();
config.tikFinityUrl = tikFinityUrl.getValue().trim();
config.prefixes.put(selectedPlatform.name(), prefix.getValue());
config.templates.put(selectedPlatform.name(), template.getValue());
config.setChatFormat(selectedPlatform, template.getValue());
config.save();
connections.configurationChanged();
minecraft.setScreen(parent);
@@ -135,11 +199,16 @@ public final class StreamChatScreen extends Screen {
config.streamerBotPassword = streamerBotPassword.getValue();
config.tikFinityUrl = tikFinityUrl.getValue().trim();
config.prefixes.put(selectedPlatform.name(), prefix.getValue());
config.templates.put(selectedPlatform.name(), template.getValue());
config.setChatFormat(selectedPlatform, template.getValue());
selectedPlatform = platform;
prefix.setValue(config.getPrefix(platform));
template.setValue(config.templates.getOrDefault(platform.name(),
"{prefix}{username} > {message}"));
template.setValue(config.getChatFormat(platform));
rebuildRoutes();
}
private void reloadConfig() {
config.reload();
connections.configurationChanged();
rebuildRoutes();
}
@@ -154,6 +223,7 @@ public final class StreamChatScreen extends Screen {
case YOUTUBE -> YOUTUBE_EVENTS;
case TIKTOK -> TIKTOK_EVENTS;
};
contentEnd = 320 + (events.size() + 1) / 2 * 24;
for (int i = 0; i < events.size(); i++) {
String eventType = events.get(i);
int x = left + (i % 2) * 195;
+5
View File
@@ -0,0 +1,5 @@
minecraft_version=1.20.1
loader_version=0.15.11
fabric_api_version=0.92.2+1.20.1
modmenu_version=7.2.2
java_version=17
+5
View File
@@ -0,0 +1,5 @@
minecraft_version=26.1.2
loader_version=0.20.1
fabric_api_version=0.155.3+26.1.2
modmenu_version=18.0.2
java_version=25