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:
@@ -3,3 +3,4 @@ build/
|
||||
run/
|
||||
*.iml
|
||||
.idea/
|
||||
dist/
|
||||
|
||||
@@ -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. `⎺FF` |
|
||||
| `&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: "⎺FF" # 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`.
|
||||
|
||||
@@ -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
@@ -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: \"⎺FF[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 \"⎺FF\". 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] ⎺FFhi");
|
||||
|
||||
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;
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user