From 9147479b009c51f0a6c4fdd2e87aed8a2e34596b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20Kr=C3=BChlmann?= Date: Wed, 14 Jan 2026 01:01:16 +0100 Subject: [PATCH] Add 7TV emote support --- .../imgfloat/config/SecurityConfig.java | 2 + .../controller/SevenTvEmoteController.java | 38 ++ .../dev/kruhlmann/imgfloat/model/Channel.java | 11 + .../model/ChannelScriptSettingsRequest.java | 16 +- .../service/ChannelDirectoryService.java | 3 + .../imgfloat/service/SevenTvEmoteService.java | 346 ++++++++++++++++++ .../V4__channel_7tv_emote_settings.sql | 5 + src/main/resources/static/js/broadcast.js | 23 +- .../resources/static/js/broadcast/renderer.js | 53 ++- src/main/resources/static/js/dashboard.js | 17 +- src/main/resources/templates/dashboard.html | 4 + 11 files changed, 510 insertions(+), 8 deletions(-) create mode 100644 src/main/java/dev/kruhlmann/imgfloat/controller/SevenTvEmoteController.java create mode 100644 src/main/java/dev/kruhlmann/imgfloat/service/SevenTvEmoteService.java create mode 100644 src/main/resources/db/migration/V4__channel_7tv_emote_settings.sql diff --git a/src/main/java/dev/kruhlmann/imgfloat/config/SecurityConfig.java b/src/main/java/dev/kruhlmann/imgfloat/config/SecurityConfig.java index e7a1aad..d6e2562 100644 --- a/src/main/java/dev/kruhlmann/imgfloat/config/SecurityConfig.java +++ b/src/main/java/dev/kruhlmann/imgfloat/config/SecurityConfig.java @@ -83,6 +83,8 @@ public class SecurityConfig { .permitAll() .requestMatchers(HttpMethod.GET, "/api/twitch/emotes/**") .permitAll() + .requestMatchers(HttpMethod.GET, "/api/7tv/emotes/**") + .permitAll() .requestMatchers("/ws/**") .permitAll() .anyRequest() diff --git a/src/main/java/dev/kruhlmann/imgfloat/controller/SevenTvEmoteController.java b/src/main/java/dev/kruhlmann/imgfloat/controller/SevenTvEmoteController.java new file mode 100644 index 0000000..ebcfc98 --- /dev/null +++ b/src/main/java/dev/kruhlmann/imgfloat/controller/SevenTvEmoteController.java @@ -0,0 +1,38 @@ +package dev.kruhlmann.imgfloat.controller; + +import dev.kruhlmann.imgfloat.service.SevenTvEmoteService; +import java.util.List; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/7tv/emotes") +public class SevenTvEmoteController { + + private final SevenTvEmoteService sevenTvEmoteService; + + public SevenTvEmoteController(SevenTvEmoteService sevenTvEmoteService) { + this.sevenTvEmoteService = sevenTvEmoteService; + } + + @GetMapping + public EmoteCatalogResponse fetchEmoteCatalog(@RequestParam(value = "channel", required = false) String channel) { + List channelEmotes = sevenTvEmoteService.getChannelEmotes(channel); + return new EmoteCatalogResponse(channelEmotes); + } + + @GetMapping("/{emoteId}") + public ResponseEntity fetchEmoteAsset(@PathVariable("emoteId") String emoteId) { + return sevenTvEmoteService + .loadEmoteAsset(emoteId) + .map((asset) -> ResponseEntity.ok().contentType(MediaType.parseMediaType(asset.mediaType())).body(asset.bytes())) + .orElseGet(() -> ResponseEntity.notFound().build()); + } + + public record EmoteCatalogResponse(List channel) {} +} diff --git a/src/main/java/dev/kruhlmann/imgfloat/model/Channel.java b/src/main/java/dev/kruhlmann/imgfloat/model/Channel.java index 618a3a6..9bee255 100644 --- a/src/main/java/dev/kruhlmann/imgfloat/model/Channel.java +++ b/src/main/java/dev/kruhlmann/imgfloat/model/Channel.java @@ -36,6 +36,9 @@ public class Channel { @Column(name = "allow_channel_emotes_for_assets", nullable = false) private boolean allowChannelEmotesForAssets = true; + @Column(name = "allow_7tv_emotes_for_assets", nullable = false) + private boolean allowSevenTvEmotesForAssets = true; + @Column(name = "allow_script_chat_access", nullable = false) private boolean allowScriptChatAccess = true; @@ -93,6 +96,14 @@ public class Channel { this.allowChannelEmotesForAssets = allowChannelEmotesForAssets; } + public boolean isAllowSevenTvEmotesForAssets() { + return allowSevenTvEmotesForAssets; + } + + public void setAllowSevenTvEmotesForAssets(boolean allowSevenTvEmotesForAssets) { + this.allowSevenTvEmotesForAssets = allowSevenTvEmotesForAssets; + } + public boolean isAllowScriptChatAccess() { return allowScriptChatAccess; } diff --git a/src/main/java/dev/kruhlmann/imgfloat/model/ChannelScriptSettingsRequest.java b/src/main/java/dev/kruhlmann/imgfloat/model/ChannelScriptSettingsRequest.java index 2fc91d5..d379d4e 100644 --- a/src/main/java/dev/kruhlmann/imgfloat/model/ChannelScriptSettingsRequest.java +++ b/src/main/java/dev/kruhlmann/imgfloat/model/ChannelScriptSettingsRequest.java @@ -3,12 +3,18 @@ package dev.kruhlmann.imgfloat.model; public class ChannelScriptSettingsRequest { private boolean allowChannelEmotesForAssets = true; + private boolean allowSevenTvEmotesForAssets = true; private boolean allowScriptChatAccess = true; public ChannelScriptSettingsRequest() {} - public ChannelScriptSettingsRequest(boolean allowChannelEmotesForAssets, boolean allowScriptChatAccess) { + public ChannelScriptSettingsRequest( + boolean allowChannelEmotesForAssets, + boolean allowSevenTvEmotesForAssets, + boolean allowScriptChatAccess + ) { this.allowChannelEmotesForAssets = allowChannelEmotesForAssets; + this.allowSevenTvEmotesForAssets = allowSevenTvEmotesForAssets; this.allowScriptChatAccess = allowScriptChatAccess; } @@ -20,6 +26,14 @@ public class ChannelScriptSettingsRequest { this.allowChannelEmotesForAssets = allowChannelEmotesForAssets; } + public boolean isAllowSevenTvEmotesForAssets() { + return allowSevenTvEmotesForAssets; + } + + public void setAllowSevenTvEmotesForAssets(boolean allowSevenTvEmotesForAssets) { + this.allowSevenTvEmotesForAssets = allowSevenTvEmotesForAssets; + } + public boolean isAllowScriptChatAccess() { return allowScriptChatAccess; } diff --git a/src/main/java/dev/kruhlmann/imgfloat/service/ChannelDirectoryService.java b/src/main/java/dev/kruhlmann/imgfloat/service/ChannelDirectoryService.java index 0b0e739..52c68e7 100644 --- a/src/main/java/dev/kruhlmann/imgfloat/service/ChannelDirectoryService.java +++ b/src/main/java/dev/kruhlmann/imgfloat/service/ChannelDirectoryService.java @@ -198,6 +198,7 @@ public class ChannelDirectoryService { Channel channel = getOrCreateChannel(broadcaster); return new ChannelScriptSettingsRequest( channel.isAllowChannelEmotesForAssets(), + channel.isAllowSevenTvEmotesForAssets(), channel.isAllowScriptChatAccess() ); } @@ -208,10 +209,12 @@ public class ChannelDirectoryService { ) { Channel channel = getOrCreateChannel(broadcaster); channel.setAllowChannelEmotesForAssets(request.isAllowChannelEmotesForAssets()); + channel.setAllowSevenTvEmotesForAssets(request.isAllowSevenTvEmotesForAssets()); channel.setAllowScriptChatAccess(request.isAllowScriptChatAccess()); channelRepository.save(channel); return new ChannelScriptSettingsRequest( channel.isAllowChannelEmotesForAssets(), + channel.isAllowSevenTvEmotesForAssets(), channel.isAllowScriptChatAccess() ); } diff --git a/src/main/java/dev/kruhlmann/imgfloat/service/SevenTvEmoteService.java b/src/main/java/dev/kruhlmann/imgfloat/service/SevenTvEmoteService.java new file mode 100644 index 0000000..8008280 --- /dev/null +++ b/src/main/java/dev/kruhlmann/imgfloat/service/SevenTvEmoteService.java @@ -0,0 +1,346 @@ +package dev.kruhlmann.imgfloat.service; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import java.io.IOException; +import java.net.URI; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.time.Duration; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.web.client.RestTemplateBuilder; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestClientException; +import org.springframework.web.client.RestTemplate; +import org.springframework.web.util.UriComponentsBuilder; + +@Service +public class SevenTvEmoteService { + + private static final Logger LOG = LoggerFactory.getLogger(SevenTvEmoteService.class); + private static final String USERS_URL = "https://api.twitch.tv/helix/users"; + private static final String USER_EMOTE_URL = "https://7tv.io/v3/users/twitch/"; + + private final RestTemplate restTemplate; + private final TwitchAppAccessTokenService tokenService; + private final Path cacheRoot; + private final Map emoteCache = new ConcurrentHashMap<>(); + private final Map> channelEmoteCache = new ConcurrentHashMap<>(); + + public SevenTvEmoteService( + RestTemplateBuilder builder, + TwitchAppAccessTokenService tokenService, + @Value("${IMGFLOAT_7TV_EMOTE_CACHE_PATH:#{null}}") String cachePath + ) { + this.restTemplate = builder + .setConnectTimeout(Duration.ofSeconds(20)) + .setReadTimeout(Duration.ofSeconds(20)) + .build(); + this.tokenService = tokenService; + String root = cachePath != null + ? cachePath + : Paths.get(System.getProperty("java.io.tmpdir"), "imgfloat-7tv-emotes").toString(); + this.cacheRoot = Paths.get(root).normalize().toAbsolutePath(); + try { + Files.createDirectories(this.cacheRoot); + } catch (IOException ex) { + throw new IllegalStateException("Failed to create 7TV emote cache directory", ex); + } + } + + public List getChannelEmotes(String channelLogin) { + if (channelLogin == null || channelLogin.isBlank()) { + return List.of(); + } + String normalized = channelLogin.toLowerCase(Locale.ROOT); + List emotes = channelEmoteCache.computeIfAbsent(normalized, this::fetchChannelEmotes); + return emotes.stream().map(CachedEmote::descriptor).toList(); + } + + public Optional loadEmoteAsset(String emoteId) { + if (emoteId == null || emoteId.isBlank()) { + return Optional.empty(); + } + CachedEmote cached = emoteCache.get(emoteId); + if (cached == null) { + cached = restoreFromDisk(emoteId).orElse(null); + } + if (cached == null) { + return Optional.empty(); + } + try { + byte[] bytes = Files.readAllBytes(cached.path()); + return Optional.of(new EmoteAsset(bytes, cached.mediaType())); + } catch (IOException ex) { + LOG.warn("Unable to read cached 7TV emote {}", emoteId, ex); + return Optional.empty(); + } + } + + private List fetchChannelEmotes(String channelLogin) { + String broadcasterId = fetchBroadcasterId(channelLogin).orElse(null); + if (broadcasterId == null) { + return List.of(); + } + String url = USER_EMOTE_URL + broadcasterId; + SevenTvUserResponse response = fetchEmotes(url).orElse(null); + SevenTvEmoteSet set = response != null ? response.emoteSet() : null; + if (set == null || set.emotes() == null || set.emotes().isEmpty()) { + return List.of(); + } + List cached = set + .emotes() + .stream() + .map(this::cacheEmote) + .filter(Optional::isPresent) + .map(Optional::get) + .toList(); + LOG.info("Loaded {} 7TV emotes for {}", cached.size(), channelLogin); + return cached; + } + + private Optional fetchBroadcasterId(String channelLogin) { + Optional token = tokenService.getAccessToken(); + Optional clientId = tokenService.getClientId(); + if (token.isEmpty() || clientId.isEmpty()) { + return Optional.empty(); + } + UriComponentsBuilder builder = UriComponentsBuilder.fromHttpUrl(USERS_URL).queryParam("login", channelLogin); + HttpHeaders headers = new HttpHeaders(); + headers.setBearerAuth(token.get()); + headers.add("Client-ID", clientId.get()); + try { + ResponseEntity response = restTemplate.exchange( + builder.build(true).toUri(), + HttpMethod.GET, + new HttpEntity<>(headers), + TwitchUsersResponse.class + ); + TwitchUsersResponse body = response.getBody(); + if (body == null || body.data() == null || body.data().isEmpty()) { + return Optional.empty(); + } + return body.data().stream().findFirst().map(TwitchUserData::id); + } catch (RestClientException ex) { + LOG.warn("Unable to fetch Twitch broadcaster id for {}", channelLogin, ex); + return Optional.empty(); + } + } + + private Optional fetchEmotes(String url) { + try { + ResponseEntity response = restTemplate.exchange( + URI.create(url), + HttpMethod.GET, + HttpEntity.EMPTY, + SevenTvUserResponse.class + ); + return Optional.ofNullable(response.getBody()); + } catch (RestClientException ex) { + LOG.warn("Unable to fetch 7TV emotes from {}", url, ex); + return Optional.empty(); + } + } + + private Optional cacheEmote(SevenTvEmote emote) { + if (emote == null || emote.id() == null || emote.id().isBlank()) { + return Optional.empty(); + } + return Optional.ofNullable( + emoteCache.computeIfAbsent( + emote.id(), + (id) -> { + String imageUrl = selectImageUrl(emote); + if (imageUrl == null) { + return null; + } + return downloadEmote(id, emote.name(), imageUrl); + } + ) + ); + } + + private CachedEmote downloadEmote(String id, String name, String imageUrl) { + Path filePath = resolveEmotePath(id, imageUrl); + MediaType mediaType = null; + + if (!Files.exists(filePath)) { + try { + ResponseEntity response = restTemplate.getForEntity(URI.create(imageUrl), byte[].class); + byte[] bytes = response.getBody(); + if (bytes == null || bytes.length == 0) { + return null; + } + mediaType = response.getHeaders().getContentType(); + Files.write(filePath, bytes); + } catch (IOException | RestClientException ex) { + LOG.warn("Unable to download 7TV emote {}", id, ex); + return null; + } + } + + if (mediaType == null) { + mediaType = mediaTypeFromPath(filePath); + } + return new CachedEmote(id, name, filePath, mediaType != null ? mediaType.toString() : "image/png"); + } + + private Optional restoreFromDisk(String id) { + if (id == null || id.isBlank()) { + return Optional.empty(); + } + try { + List candidates; + try (var stream = Files.list(cacheRoot)) { + candidates = stream.filter((path) -> path.getFileName().toString().startsWith(id + ".")).toList(); + } + if (candidates.isEmpty()) { + return Optional.empty(); + } + Path path = candidates.get(0); + MediaType mediaType = mediaTypeFromPath(path); + CachedEmote cached = new CachedEmote(id, id, path, mediaType != null ? mediaType.toString() : "image/png"); + emoteCache.put(id, cached); + return Optional.of(cached); + } catch (IOException ex) { + LOG.warn("Unable to restore cached 7TV emote {}", id, ex); + return Optional.empty(); + } + } + + private Path resolveEmotePath(String id, String imageUrl) { + String extension = extensionFromUrl(imageUrl).orElse("png"); + return cacheRoot.resolve(id + "." + extension); + } + + private Optional extensionFromUrl(String imageUrl) { + if (imageUrl == null) { + return Optional.empty(); + } + try { + String path = URI.create(imageUrl).getPath(); + int dot = path.lastIndexOf('.'); + if (dot == -1) { + return Optional.empty(); + } + return Optional.of(path.substring(dot + 1)); + } catch (IllegalArgumentException ex) { + return Optional.empty(); + } + } + + private MediaType mediaTypeFromPath(Path path) { + if (path == null) { + return null; + } + String name = path.getFileName().toString().toLowerCase(Locale.ROOT); + if (name.endsWith(".gif")) { + return MediaType.IMAGE_GIF; + } + if (name.endsWith(".webp")) { + return MediaType.parseMediaType("image/webp"); + } + if (name.endsWith(".avif")) { + return MediaType.parseMediaType("image/avif"); + } + if (name.endsWith(".jpg") || name.endsWith(".jpeg")) { + return MediaType.IMAGE_JPEG; + } + return MediaType.IMAGE_PNG; + } + + private String selectImageUrl(SevenTvEmote emote) { + if (emote == null || emote.data() == null || emote.data().host() == null) { + return null; + } + SevenTvHost host = emote.data().host(); + if (host.files() == null || host.files().isEmpty()) { + return null; + } + SevenTvFile selected = selectBestFile(host.files()); + if (selected == null || selected.name() == null || selected.name().isBlank()) { + return null; + } + String hostUrl = normalizeHostUrl(host.url()); + if (hostUrl == null) { + return null; + } + return hostUrl + "/" + selected.name(); + } + + private SevenTvFile selectBestFile(List files) { + if (files == null || files.isEmpty()) { + return null; + } + for (String size : List.of("4x", "3x", "2x", "1x")) { + Optional match = files.stream().filter((file) -> size.equals(file.size())).findFirst(); + if (match.isPresent()) { + return match.get(); + } + } + return files.get(0); + } + + private String normalizeHostUrl(String url) { + if (url == null || url.isBlank()) { + return null; + } + String normalized = url; + if (normalized.startsWith("//")) { + normalized = "https:" + normalized; + } else if (!normalized.startsWith("http")) { + normalized = "https://" + normalized; + } + if (normalized.endsWith("/")) { + normalized = normalized.substring(0, normalized.length() - 1); + } + return normalized; + } + + public record EmoteDescriptor(String id, String name, String url) {} + + public record EmoteAsset(byte[] bytes, String mediaType) {} + + private record CachedEmote(String id, String name, Path path, String mediaType) { + EmoteDescriptor descriptor() { + return new EmoteDescriptor(id, name, "/api/7tv/emotes/" + id); + } + } + + @JsonIgnoreProperties(ignoreUnknown = true) + private record SevenTvUserResponse(@JsonProperty("emote_set") SevenTvEmoteSet emoteSet) {} + + @JsonIgnoreProperties(ignoreUnknown = true) + private record SevenTvEmoteSet(List emotes) {} + + @JsonIgnoreProperties(ignoreUnknown = true) + private record SevenTvEmote(String id, String name, SevenTvEmoteData data) {} + + @JsonIgnoreProperties(ignoreUnknown = true) + private record SevenTvEmoteData(SevenTvHost host) {} + + @JsonIgnoreProperties(ignoreUnknown = true) + private record SevenTvHost(String url, List files) {} + + @JsonIgnoreProperties(ignoreUnknown = true) + private record SevenTvFile(String name, String format, String size) {} + + @JsonIgnoreProperties(ignoreUnknown = true) + private record TwitchUsersResponse(List data) {} + + @JsonIgnoreProperties(ignoreUnknown = true) + private record TwitchUserData(String id) {} +} diff --git a/src/main/resources/db/migration/V4__channel_7tv_emote_settings.sql b/src/main/resources/db/migration/V4__channel_7tv_emote_settings.sql new file mode 100644 index 0000000..6ba45bc --- /dev/null +++ b/src/main/resources/db/migration/V4__channel_7tv_emote_settings.sql @@ -0,0 +1,5 @@ +ALTER TABLE channels ADD COLUMN allow_7tv_emotes_for_assets BOOLEAN NOT NULL DEFAULT TRUE; + +UPDATE channels +SET allow_7tv_emotes_for_assets = TRUE +WHERE allow_7tv_emotes_for_assets IS NULL; diff --git a/src/main/resources/static/js/broadcast.js b/src/main/resources/static/js/broadcast.js index 87d968a..36645d5 100644 --- a/src/main/resources/static/js/broadcast.js +++ b/src/main/resources/static/js/broadcast.js @@ -9,6 +9,7 @@ setUpElectronWindowFrame(); const renderer = new BroadcastRenderer({ canvas, scriptLayer, broadcaster, showToast }); const defaultScriptSettings = { allowChannelEmotesForAssets: true, + allowSevenTvEmotesForAssets: true, allowScriptChatAccess: true, }; let currentScriptSettings = { ...defaultScriptSettings }; @@ -29,15 +30,33 @@ const settingsPromise = fetch(`/api/channels/${encodeURIComponent(broadcaster)}/ renderer.setScriptSettings(defaultScriptSettings); }); -fetch(`/api/twitch/emotes?channel=${encodeURIComponent(broadcaster)}`) +const emoteCatalog = { global: [], channel: [], sevenTv: [] }; +const twitchEmotePromise = fetch(`/api/twitch/emotes?channel=${encodeURIComponent(broadcaster)}`) .then((response) => { if (!response.ok) { throw new Error("Failed to load Twitch emotes"); } return response.json(); }) - .then((catalog) => renderer.setEmoteCatalog(catalog)) + .then((catalog) => { + emoteCatalog.global = Array.isArray(catalog?.global) ? catalog.global : []; + emoteCatalog.channel = Array.isArray(catalog?.channel) ? catalog.channel : []; + }) .catch((error) => console.warn("Unable to load Twitch emotes", error)); + +const sevenTvEmotePromise = fetch(`/api/7tv/emotes?channel=${encodeURIComponent(broadcaster)}`) + .then((response) => { + if (!response.ok) { + throw new Error("Failed to load 7TV emotes"); + } + return response.json(); + }) + .then((catalog) => { + emoteCatalog.sevenTv = Array.isArray(catalog?.channel) ? catalog.channel : []; + }) + .catch((error) => console.warn("Unable to load 7TV emotes", error)); + +Promise.allSettled([twitchEmotePromise, sevenTvEmotePromise]).then(() => renderer.setEmoteCatalog(emoteCatalog)); let disconnectChat = () => {}; settingsPromise.finally(() => { if (!currentScriptSettings.allowScriptChatAccess) { diff --git a/src/main/resources/static/js/broadcast/renderer.js b/src/main/resources/static/js/broadcast/renderer.js index 7752bc4..46fbcf9 100644 --- a/src/main/resources/static/js/broadcast/renderer.js +++ b/src/main/resources/static/js/broadcast/renderer.js @@ -30,8 +30,11 @@ export class BroadcastRenderer { this.emoteCatalogById = new Map(); this.globalEmotes = []; this.channelEmotes = []; + this.sevenTvEmotes = []; + this.sevenTvEmotesByName = new Map(); this.lastChatPruneAt = 0; this.allowChannelEmotesForAssets = true; + this.allowSevenTvEmotesForAssets = true; this.allowScriptChatAccess = true; this.obsBrowser = !!globalThis.obsstudio; @@ -429,6 +432,7 @@ export class BroadcastRenderer { setScriptSettings(settings) { this.allowChannelEmotesForAssets = settings?.allowChannelEmotesForAssets !== false; + this.allowSevenTvEmotesForAssets = settings?.allowSevenTvEmotesForAssets !== false; this.allowScriptChatAccess = settings?.allowScriptChatAccess !== false; if (!this.allowScriptChatAccess) { this.chatMessages = []; @@ -478,15 +482,22 @@ export class BroadcastRenderer { setEmoteCatalog(catalog) { this.globalEmotes = Array.isArray(catalog?.global) ? catalog.global : []; this.channelEmotes = Array.isArray(catalog?.channel) ? catalog.channel : []; + this.sevenTvEmotes = Array.isArray(catalog?.sevenTv) ? catalog.sevenTv : []; this.refreshEmoteCatalog(); } refreshEmoteCatalog() { const allowedChannelEmotes = this.allowChannelEmotesForAssets ? this.channelEmotes : []; - this.emoteCatalog = [...this.globalEmotes, ...allowedChannelEmotes]; + const allowedSevenTvEmotes = this.allowSevenTvEmotesForAssets ? this.sevenTvEmotes : []; + this.emoteCatalog = [...this.globalEmotes, ...allowedChannelEmotes, ...allowedSevenTvEmotes]; this.emoteCatalogById = new Map( this.emoteCatalog.map((entry) => [String(entry?.id || ""), entry]).filter(([key]) => key), ); + this.sevenTvEmotesByName = new Map( + allowedSevenTvEmotes + .map((entry) => [String(entry?.name || ""), entry]) + .filter(([key]) => key), + ); if (this.chatMessages.length) { this.chatMessages = this.chatMessages.map((message) => { const fragments = this.buildMessageFragments(message.message || "", message.tags); @@ -534,7 +545,7 @@ export class BroadcastRenderer { } const emotes = this.parseEmoteOffsets(tags?.emotes); if (!emotes.length) { - return [{ type: "text", text: message }]; + return this.applySevenTvEmotes([{ type: "text", text: message }]); } const sorted = emotes.sort((a, b) => a.start - b.start); const fragments = []; @@ -561,7 +572,43 @@ export class BroadcastRenderer { if (cursor < message.length) { fragments.push({ type: "text", text: message.slice(cursor) }); } - return fragments; + return this.applySevenTvEmotes(fragments); + } + + applySevenTvEmotes(fragments) { + if (!this.sevenTvEmotesByName.size) { + return fragments; + } + const enhanced = []; + fragments.forEach((fragment) => { + if (fragment?.type !== "text" || !fragment.text) { + enhanced.push(fragment); + return; + } + const parts = fragment.text.split(/(\\s+)/); + parts.forEach((part) => { + if (!part) { + return; + } + if (/^\\s+$/.test(part)) { + enhanced.push({ type: "text", text: part }); + return; + } + const emote = this.sevenTvEmotesByName.get(part); + if (emote) { + enhanced.push({ + type: "emote", + id: emote.id, + text: part, + name: emote.name || part, + url: emote.url || null, + }); + } else { + enhanced.push({ type: "text", text: part }); + } + }); + }); + return enhanced; } receiveChatMessage(message) { diff --git a/src/main/resources/static/js/dashboard.js b/src/main/resources/static/js/dashboard.js index 50072ed..ed05a7e 100644 --- a/src/main/resources/static/js/dashboard.js +++ b/src/main/resources/static/js/dashboard.js @@ -8,6 +8,7 @@ const elements = { canvasStatus: document.getElementById("canvas-status"), canvasSaveButton: document.getElementById("save-canvas-btn"), allowChannelEmotes: document.getElementById("allow-channel-emotes"), + allowSevenTvEmotes: document.getElementById("allow-7tv-emotes"), allowScriptChat: document.getElementById("allow-script-chat"), scriptSettingsStatus: document.getElementById("script-settings-status"), scriptSettingsSaveButton: document.getElementById("save-script-settings-btn"), @@ -250,6 +251,9 @@ function renderScriptSettings(settings) { if (elements.allowChannelEmotes) { elements.allowChannelEmotes.checked = settings.allowChannelEmotesForAssets !== false; } + if (elements.allowSevenTvEmotes) { + elements.allowSevenTvEmotes.checked = settings.allowSevenTvEmotesForAssets !== false; + } if (elements.allowScriptChat) { elements.allowScriptChat.checked = settings.allowScriptChatAccess !== false; } @@ -260,13 +264,18 @@ async function fetchScriptSettings() { const data = await fetchJson("/settings", {}, "Failed to load script settings"); renderScriptSettings(data); } catch (error) { - renderScriptSettings({ allowChannelEmotesForAssets: true, allowScriptChatAccess: true }); + renderScriptSettings({ + allowChannelEmotesForAssets: true, + allowSevenTvEmotesForAssets: true, + allowScriptChatAccess: true, + }); showToast("Using default script settings. Unable to load saved preferences.", "warning"); } } async function saveScriptSettings() { const allowChannelEmotesForAssets = elements.allowChannelEmotes?.checked ?? true; + const allowSevenTvEmotesForAssets = elements.allowSevenTvEmotes?.checked ?? true; const allowScriptChatAccess = elements.allowScriptChat?.checked ?? true; if (elements.scriptSettingsStatus) elements.scriptSettingsStatus.textContent = "Saving..."; setButtonBusy(elements.scriptSettingsSaveButton, true, "Saving..."); @@ -276,7 +285,11 @@ async function saveScriptSettings() { { method: "PUT", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ allowChannelEmotesForAssets, allowScriptChatAccess }), + body: JSON.stringify({ + allowChannelEmotesForAssets, + allowSevenTvEmotesForAssets, + allowScriptChatAccess, + }), }, "Failed to save script settings", ); diff --git a/src/main/resources/templates/dashboard.html b/src/main/resources/templates/dashboard.html index fd7d607..fdfb187 100644 --- a/src/main/resources/templates/dashboard.html +++ b/src/main/resources/templates/dashboard.html @@ -94,6 +94,10 @@ Allow script assets to use this channel's Twitch emotes. +