diff --git a/CHANGELOG-nightly.md b/CHANGELOG-nightly.md index 5e304946..ac6bd343 100644 --- a/CHANGELOG-nightly.md +++ b/CHANGELOG-nightly.md @@ -1,3 +1,7 @@ +### 3.1.16.1000 + +- Added EloWard League of Legends rank badges module + ### 3.1.15.1000 - Added required Firefox built-in consent metadata to the manifest diff --git a/src/app/chat/UserTag.vue b/src/app/chat/UserTag.vue index 64dd74b3..25fbaea7 100644 --- a/src/app/chat/UserTag.vue +++ b/src/app/chat/UserTag.vue @@ -8,7 +8,8 @@ @@ -36,6 +37,9 @@ type="app" /> + + + @@ -74,6 +78,10 @@ import { useChannelContext } from "@/composable/channel/useChannelContext"; import { useChatProperties } from "@/composable/chat/useChatProperties"; import { useCosmetics } from "@/composable/useCosmetics"; import { useConfig } from "@/composable/useSettings"; +import EloWardBadge from "@/site/twitch.tv/modules/eloward/components/EloWardBadge.vue"; +import { useEloWardRanks } from "@/site/twitch.tv/modules/eloward/composables/useEloWardRanks"; +import type { EloWardBadge as EloWardBadgeType } from "@/site/twitch.tv/modules/eloward/composables/useEloWardRanks"; +import { useGameDetection } from "@/site/twitch.tv/modules/eloward/composables/useGameDetection"; import Badge from "./Badge.vue"; import UserCard from "./UserCard.vue"; import UiDraggable from "@/ui/UiDraggable.vue"; @@ -115,6 +123,11 @@ const twitchBadges = ref([]); const twitchBadgeSets = toRef(properties, "twitchBadgeSets"); const mentionStyle = useConfig("chat.colored_mentions"); +// EloWard integration +const elowardRanks = useEloWardRanks(); +const gameDetection = useGameDetection(); +const elowardEnabled = useConfig("eloward.enabled"); + const tagRef = ref(); const showUserCard = ref(false); const cardHandle = ref(); @@ -183,6 +196,58 @@ const stop = watch( }, { immediate: true }, ); + +// EloWard badge integration +const elowardBadge = ref(null); + +// Detect official EloWard Chrome extension +function detectOfficialExtension(): boolean { + const bodyAttr = document.body?.getAttribute("data-eloward-chrome-ext"); + const htmlAttr = document.documentElement?.getAttribute("data-eloward-chrome-ext"); + + return bodyAttr === "active" || htmlAttr === "active"; +} + +const initializeEloWardBadge = () => { + // Skip if official EloWard Chrome extension is detected + if (detectOfficialExtension()) { + elowardBadge.value = null; + return; + } + + if (!elowardEnabled.value || !gameDetection.isLeagueStream.value) { + elowardBadge.value = null; + return; + } + + const username = props.user.username; + if (!username) { + elowardBadge.value = null; + return; + } + + const cachedData = elowardRanks.getCachedRankData(username); + + if (cachedData !== undefined) { + elowardBadge.value = cachedData ? elowardRanks.getRankBadge(cachedData) : null; + return; + } + + elowardRanks + .fetchRankData(username) + .then((rankData) => { + elowardBadge.value = rankData ? elowardRanks.getRankBadge(rankData) : null; + }) + .catch(() => { + elowardBadge.value = null; + }); +}; + +initializeEloWardBadge(); + +watch(() => props.user.username, initializeEloWardBadge); +watch(elowardEnabled, initializeEloWardBadge); +watch(() => gameDetection.isLeagueStream.value, initializeEloWardBadge); diff --git a/src/site/twitch.tv/modules/eloward/components/EloWardTooltip.vue b/src/site/twitch.tv/modules/eloward/components/EloWardTooltip.vue new file mode 100644 index 00000000..6938e701 --- /dev/null +++ b/src/site/twitch.tv/modules/eloward/components/EloWardTooltip.vue @@ -0,0 +1,147 @@ + + + + + diff --git a/src/site/twitch.tv/modules/eloward/composables/useEloWardRanks.ts b/src/site/twitch.tv/modules/eloward/composables/useEloWardRanks.ts new file mode 100644 index 00000000..35502681 --- /dev/null +++ b/src/site/twitch.tv/modules/eloward/composables/useEloWardRanks.ts @@ -0,0 +1,328 @@ +import { type Ref, ref } from "vue"; +import { useConfig } from "@/composable/useSettings"; + +const API_BASE_URL = "https://eloward-ranks.unleashai.workers.dev/api/ranks/lol"; +const CDN_BASE_URL = "https://eloward-cdn.unleashai.workers.dev/lol"; +const DEFAULT_CACHE_DURATION = 60 * 60 * 1000; +const NEGATIVE_CACHE_DURATION = 15 * 60 * 1000; +const MAX_CACHE_SIZE = 500; +const BADGE_CACHE_VERSION = "3"; + +interface CacheEntry { + data: EloWardRankData | null; // null for negative cache + timestamp: number; +} + +class LRUCache { + private cache: Map; + private maxSize: number; + + constructor(maxSize = 500) { + this.cache = new Map(); + this.maxSize = maxSize; + } + + get(username: string): EloWardRankData | null | undefined { + const key = username.toLowerCase(); + const entry = this.cache.get(key); + + if (!entry) return undefined; // Not in cache + + // Check if cache is expired + const now = Date.now(); + const cacheDuration = + entry.data === null + ? NEGATIVE_CACHE_DURATION // Shorter duration for negative cache + : DEFAULT_CACHE_DURATION; + + if (now - entry.timestamp > cacheDuration) { + this.cache.delete(key); + return undefined; + } + + // Move to end (most recently used) + this.cache.delete(key); + this.cache.set(key, entry); + + return entry.data; // Can be null (negative cache) or data + } + + set(username: string, data: EloWardRankData | null) { + const key = username.toLowerCase(); + + // Remove oldest entry if at max size + if (this.cache.size >= this.maxSize && !this.cache.has(key)) { + const firstKey = this.cache.keys().next().value; + if (firstKey) this.cache.delete(firstKey); + } + + this.cache.set(key, { + data, + timestamp: Date.now(), + }); + } + + clear() { + this.cache.clear(); + } + + size(): number { + return this.cache.size; + } +} + +// Global cache instance +const rankCache = new LRUCache(MAX_CACHE_SIZE); + +const pendingRequests = new Map>(); + +const RANK_TIERS = new Set([ + "iron", + "bronze", + "silver", + "gold", + "platinum", + "emerald", + "diamond", + "master", + "grandmaster", + "challenger", + "unranked", +]); + +function getImageUrl(tier: string, isAnimated: boolean): string { + const extension = isAnimated ? ".webp" : ".png"; + const suffix = isAnimated ? "_premium" : ""; + return `${CDN_BASE_URL}/${tier}${suffix}${extension}?v=${BADGE_CACHE_VERSION}`; +} + +export interface EloWardRankData { + tier: string; + division?: string; + leaguePoints?: number; + summonerName?: string; + region?: string; + animate_badge?: boolean; +} + +export interface EloWardBadge { + id: string; + tier: string; + division?: string; + imageUrl: string; + animated: boolean; + summonerName?: string; + region?: string; + leaguePoints?: number; +} + +let cachedEnabledConfig: ReturnType> | null = null; +let cachedIsLoading: Ref | null = null; + +export function useEloWardRanks() { + if (!cachedEnabledConfig) { + cachedEnabledConfig = useConfig("eloward.enabled"); + cachedIsLoading = ref(false); + } + + const enabled = cachedEnabledConfig!; + const isLoading = cachedIsLoading!; + + function getCachedRankData(username: string): EloWardRankData | null | undefined { + if (!enabled.value || !username) return undefined; + return rankCache.get(username.toLowerCase()); + } + + async function fetchRankData(username: string): Promise { + if (!enabled.value || !username) { + return null; + } + + const normalizedUsername = username.toLowerCase(); + + const cached = rankCache.get(normalizedUsername); + if (cached !== undefined) { + return cached; + } + + if (pendingRequests.has(normalizedUsername)) { + return pendingRequests.get(normalizedUsername)!; + } + + const requestPromise = (async () => { + try { + isLoading.value = true; + + const response = await fetch(`${API_BASE_URL}/${normalizedUsername}`, { + method: "GET", + headers: { + Accept: "application/json", + }, + signal: AbortSignal.timeout(5000), + }); + + if (response.status === 404) { + rankCache.set(normalizedUsername, null); + return null; + } + + if (!response.ok) { + return null; + } + + const data = await response.json(); + + if (!data.rank_tier || !RANK_TIERS.has(data.rank_tier.toLowerCase())) { + rankCache.set(normalizedUsername, null); + return null; + } + + const rankData: EloWardRankData = { + tier: data.rank_tier, + division: data.rank_division, + leaguePoints: data.lp, + summonerName: data.riot_id, + region: data.region, + animate_badge: data.animate_badge, + }; + + rankCache.set(normalizedUsername, rankData); + return rankData; + } catch (error) { + return null; + } finally { + isLoading.value = false; + pendingRequests.delete(normalizedUsername); + } + })(); + + pendingRequests.set(normalizedUsername, requestPromise); + return requestPromise; + } + + function getRankBadge(rankData: EloWardRankData): EloWardBadge | null { + if (!rankData?.tier) return null; + + const tier = rankData.tier.toLowerCase(); + if (!RANK_TIERS.has(tier)) return null; + + const shouldAnimate = Boolean(rankData.animate_badge); + const imageUrl = getImageUrl(tier, shouldAnimate); + + return { + id: `eloward-${tier}${rankData.division ? `-${rankData.division}` : ""}`, + tier: rankData.tier.toUpperCase(), + division: rankData.division, + imageUrl, + animated: shouldAnimate, + summonerName: rankData.summonerName, + region: rankData.region, + leaguePoints: rankData.leaguePoints, + }; + } + + /** + * Format rank text for display + */ + function formatRankText(rankData: EloWardRankData): string { + if (!rankData?.tier) return "UNRANKED"; + + const tierUpper = rankData.tier.toUpperCase(); + if (tierUpper === "UNRANKED") return "UNRANKED"; + + let rankText = tierUpper; + + if (rankData.division && !["MASTER", "GRANDMASTER", "CHALLENGER"].includes(tierUpper)) { + rankText += ` ${rankData.division}`; + } + + if (rankData.leaguePoints !== undefined && rankData.leaguePoints !== null) { + rankText += ` - ${rankData.leaguePoints} LP`; + } + + return rankText; + } + + /** + * Get region display name + */ + function getRegionDisplay(region?: string): string { + if (!region) return ""; + + const regionMap: Record = { + na1: "NA", + euw1: "EUW", + eun1: "EUNE", + kr: "KR", + br1: "BR", + jp1: "JP", + la1: "LAN", + la2: "LAS", + oc1: "OCE", + tr1: "TR", + ru: "RU", + ph2: "PH", + sg2: "SG", + th2: "TH", + tw2: "TW", + vn2: "VN", + me1: "ME", + sea: "SEA", + }; + + return regionMap[region.toLowerCase()] || region.toUpperCase(); + } + + /** + * Build OP.GG URL for a player + */ + function getOpGGUrl(rankData: EloWardRankData): string | null { + if (!rankData?.summonerName || !rankData?.region) return null; + + const regionMapping: Record = { + na1: "na", + euw1: "euw", + eun1: "eune", + kr: "kr", + br1: "br", + jp1: "jp", + la1: "lan", + la2: "las", + oc1: "oce", + tr1: "tr", + ru: "ru", + ph2: "ph", + sg2: "sg", + th2: "th", + tw2: "tw", + vn2: "vn", + me1: "me", + }; + + const opGGRegion = regionMapping[rankData.region.toLowerCase()]; + if (!opGGRegion) return null; + + const [summonerName, tagLine] = rankData.summonerName.split("#"); + const encodedName = encodeURIComponent(summonerName); + const tag = tagLine || rankData.region.toUpperCase(); + + return `https://op.gg/lol/summoners/${opGGRegion}/${encodedName}-${tag}`; + } + + function clearCache() { + rankCache.clear(); + pendingRequests.clear(); + } + + return { + fetchRankData, + getCachedRankData, + getRankBadge, + formatRankText, + getRegionDisplay, + getOpGGUrl, + clearCache, + isLoading, + cacheSize: () => rankCache.size(), + }; +} diff --git a/src/site/twitch.tv/modules/eloward/composables/useGameDetection.ts b/src/site/twitch.tv/modules/eloward/composables/useGameDetection.ts new file mode 100644 index 00000000..4fb435ca --- /dev/null +++ b/src/site/twitch.tv/modules/eloward/composables/useGameDetection.ts @@ -0,0 +1,203 @@ +import { computed, ref, watch } from "vue"; + +const LEAGUE_OF_LEGENDS_ID = "21779"; +const LEAGUE_OF_LEGENDS_NAME = "League of Legends"; + +const currentGame = ref(""); +const currentGameId = ref(""); +const lastPathname = ref(""); +const hasCheckedForCurrentPage = ref(false); +let checkTimeout: number | null = null; +let isInitialized = false; + +export function useGameDetection() { + /** + * Extract game name from Twitch directory URL + * Turns /directory/category/league-of-legends into "League of Legends" + */ + function extractGameFromHref(href: string | null | undefined): string | null { + if (!href) return null; + + const match = href.match(/\/directory\/category\/([^/?#]+)/i); + if (!match) return null; + + // Convert slug to title case (league-of-legends -> League of Legends) + return match[1] + .split("-") + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(" "); + } + + /** + * Get game from VOD page + */ + function getVodGameFromDom(): string | null { + // Primary VOD selector + const vodGame = document.querySelector("a[data-a-target='video-info-game-boxart-link'] p"); + const text = vodGame?.textContent?.trim(); + if (text) { + return text; + } + + // Fallback selectors for carousels or metadata + const previewGameLink = document.querySelector( + "a[data-test-selector='preview-card-game-link'], a[data-a-target='preview-card-game-link']", + ); + const text2 = previewGameLink?.textContent?.trim(); + if (text2) { + return text2; + } + + return null; + } + + /** + * Get game from channel page DOM + */ + function getChannelGameFromDom(): string | null { + // Priority 1: canonical Twitch selector in channel header + const header = document.querySelector("#live-channel-stream-information, .channel-info-content"); + const link = header?.querySelector("a[data-a-target='stream-game-link']"); + const text = link?.textContent?.trim(); + if (text) { + return text; + } + + // Priority 2: any visible category link in the header/content + const anyCat = header?.querySelector("a[href^='/directory/category/']"); + const text2 = anyCat?.textContent?.trim(); + if (text2) { + return text2; + } + + // Priority 3: derive from href slug if text is empty + const byHref = extractGameFromHref(link?.getAttribute("href") || anyCat?.getAttribute("href") || null); + if (byHref) { + return byHref; + } + + // Priority 4: broader search (late-loading layouts) + const globalCat = document.querySelector( + "a[data-a-target='stream-game-link'], a[href^='/directory/category/']", + ); + const globalText = globalCat?.textContent?.trim(); + if (globalText) { + return globalText; + } + + const byHrefGlobal = extractGameFromHref(globalCat?.getAttribute("href") || null); + if (byHrefGlobal) { + return byHrefGlobal; + } + + return null; + } + + /** + * Check if current page is a VOD + */ + function isVodPage(): boolean { + return window.location.pathname.includes("/videos/") || window.location.pathname.includes("/video/"); + } + + function updateGame() { + // Only skip if we already have a valid game detected (not null) + // This allows retries when DOM wasn't ready + if ( + hasCheckedForCurrentPage.value && + window.location.pathname === lastPathname.value && + currentGame.value !== "" + ) { + return; + } + + let detectedGame: string | null = null; + + if (isVodPage()) { + detectedGame = getVodGameFromDom(); + } else { + detectedGame = getChannelGameFromDom(); + } + + if (detectedGame !== currentGame.value) { + currentGame.value = detectedGame || ""; + + if (detectedGame === LEAGUE_OF_LEGENDS_NAME) { + currentGameId.value = LEAGUE_OF_LEGENDS_ID; + } else { + currentGameId.value = ""; + } + } + + // Only mark as checked if we got a valid result + if (detectedGame !== null) { + hasCheckedForCurrentPage.value = true; + } + lastPathname.value = window.location.pathname; + } + + function startObserving() { + if (checkTimeout) { + clearTimeout(checkTimeout); + checkTimeout = null; + } + + hasCheckedForCurrentPage.value = false; + updateGame(); + + // Retry multiple times with increasing delays to ensure DOM is ready + const retryDelays = [500, 1500, 3000]; + retryDelays.forEach((delay) => { + window.setTimeout(() => { + updateGame(); + }, delay); + }); + } + + /** + * Stop observing + */ + function stopObserving() { + if (checkTimeout) { + clearTimeout(checkTimeout); + checkTimeout = null; + } + } + + // Watch for navigation changes + watch( + () => window.location.pathname, + (newPath, oldPath) => { + if (newPath !== oldPath) { + // Reset and check again on navigation + startObserving(); + } + }, + ); + + // Computed property to check if it's a League stream + const isLeagueStream = computed(() => { + const isLeague = + currentGameId.value === LEAGUE_OF_LEGENDS_ID || + currentGame.value === LEAGUE_OF_LEGENDS_NAME || + currentGame.value.toLowerCase().includes("league of legends"); + + return isLeague; + }); + + if (!isInitialized) { + isInitialized = true; + setTimeout(() => { + startObserving(); + }, 100); + } + + return { + currentGame, + currentGameId, + isLeagueStream, + updateGame, + startObserving, + stopObserving, + }; +} diff --git a/src/types/tw.module.d.ts b/src/types/tw.module.d.ts index c1cb2311..646d2e67 100644 --- a/src/types/tw.module.d.ts +++ b/src/types/tw.module.d.ts @@ -4,6 +4,7 @@ import type ChatInputControllerComponent from "@/site/twitch.tv/modules/chat-inp import type ChatInputModuleVue from "@/site/twitch.tv/modules/chat-input/ChatInputModule.vue"; import type ChatVodModuleVue from "@/site/twitch.tv/modules/chat-vod/ChatVodModule.vue"; import type ChatModuleVue from "@/site/twitch.tv/modules/chat/ChatModule.vue"; +import type EloWardModuleVue from "@/site/twitch.tv/modules/eloward/EloWardModule.vue"; import type EmoteMenuModuleVue from "@/site/twitch.tv/modules/emote-menu/EmoteMenuModule.vue"; import type HiddenElementsModuleVue from "@/site/twitch.tv/modules/hidden-elements/HiddenElementsModule.vue"; import type ModLogsModule from "@/site/twitch.tv/modules/mod-logs/ModLogsModule.vue"; @@ -24,6 +25,7 @@ declare type TwModuleComponentMap = { autoclaim: typeof AutoclaimModuleVue; avatars: typeof AvatarsModuleVue; chat: typeof ChatModuleVue; + eloward: typeof EloWardModuleVue; player: PlayerModule; settings: typeof SettingsModuleVue; };