From 9384c57f43e826490fd7c6b70ab433e15b6bf720 Mon Sep 17 00:00:00 2001 From: minster586 <43217359+minster586@users.noreply.github.com> Date: Mon, 25 Aug 2025 01:16:23 -0400 Subject: [PATCH] sync from github --- README.md | 164 +-- chat.html | 172 +-- chatrd.sb | 1 + css/chatrd.css | 731 ++++++++++ css/settings.css | 715 +++++----- images/img-empty.png | Bin 0 -> 7419 bytes images/logo-speakerbot.png | Bin 0 -> 21931 bytes images/logo-streamerbot.png | Bin 0 -> 30881 bytes index.html | 1183 ++++++++++++----- js/chatrd.js | 680 ++++++++++ .../fourthwall/images/logo-fourthwall.svg | 12 + js/modules/fourthwall/module.css | 11 + js/modules/fourthwall/module.js | 291 ++++ js/modules/kick/images/badge-bot.svg | 1 + js/modules/kick/images/badge-broadcaster.svg | 1 + js/modules/kick/images/badge-founder.svg | 1 + js/modules/kick/images/badge-moderator.svg | 1 + js/modules/kick/images/badge-og.svg | 1 + js/modules/kick/images/badge-sidekick.svg | 1 + js/modules/kick/images/badge-sub_gifter.svg | 1 + js/modules/kick/images/badge-subscriber.svg | 1 + js/modules/kick/images/badge-verified.svg | 1 + js/modules/kick/images/badge-vip.svg | 1 + js/modules/kick/images/logo-kick.svg | 12 + js/modules/kick/module.css | 24 + js/modules/kick/module.js | 726 ++++++++++ js/modules/kofi/images/logo-kofi.svg | 38 + js/modules/kofi/module.css | 11 + js/modules/kofi/module.js | 192 +++ js/modules/patreon/images/logo-patreon.svg | 12 + js/modules/patreon/module.css | 3 + js/modules/patreon/module.js | 60 + .../images/logo-streamelements.svg | 82 ++ js/modules/streamelements/module.css | 3 + js/modules/streamelements/module.js | 57 + .../streamlabs/images/logo-streamlabs.svg | 14 + js/modules/streamlabs/module.css | 3 + js/modules/streamlabs/module.js | 59 + js/modules/tiktok/images/logo-tiktok.svg | 12 + js/modules/tiktok/module.css | 30 + js/modules/tiktok/module.js | 435 ++++++ .../tipeeestream/images/logo-tipeeestream.svg | 13 + js/modules/tipeeestream/module.css | 3 + js/modules/tipeeestream/module.js | 61 + js/modules/twitch/images/logo-twitch.svg | 17 + js/modules/twitch/module.css | 34 + js/modules/twitch/module.js | 765 +++++++++++ js/modules/youtube/images/logo-youtube.svg | 17 + js/modules/youtube/module.css | 57 + js/modules/youtube/module.js | 509 +++++++ js/sb.js | 67 + js/settings.js | 863 ++++++------ js/speakerbot.js | 139 ++ 53 files changed, 6941 insertions(+), 1347 deletions(-) create mode 100644 chatrd.sb create mode 100644 css/chatrd.css create mode 100644 images/img-empty.png create mode 100644 images/logo-speakerbot.png create mode 100644 images/logo-streamerbot.png create mode 100644 js/chatrd.js create mode 100644 js/modules/fourthwall/images/logo-fourthwall.svg create mode 100644 js/modules/fourthwall/module.css create mode 100644 js/modules/fourthwall/module.js create mode 100644 js/modules/kick/images/badge-bot.svg create mode 100644 js/modules/kick/images/badge-broadcaster.svg create mode 100644 js/modules/kick/images/badge-founder.svg create mode 100644 js/modules/kick/images/badge-moderator.svg create mode 100644 js/modules/kick/images/badge-og.svg create mode 100644 js/modules/kick/images/badge-sidekick.svg create mode 100644 js/modules/kick/images/badge-sub_gifter.svg create mode 100644 js/modules/kick/images/badge-subscriber.svg create mode 100644 js/modules/kick/images/badge-verified.svg create mode 100644 js/modules/kick/images/badge-vip.svg create mode 100644 js/modules/kick/images/logo-kick.svg create mode 100644 js/modules/kick/module.css create mode 100644 js/modules/kick/module.js create mode 100644 js/modules/kofi/images/logo-kofi.svg create mode 100644 js/modules/kofi/module.css create mode 100644 js/modules/kofi/module.js create mode 100644 js/modules/patreon/images/logo-patreon.svg create mode 100644 js/modules/patreon/module.css create mode 100644 js/modules/patreon/module.js create mode 100644 js/modules/streamelements/images/logo-streamelements.svg create mode 100644 js/modules/streamelements/module.css create mode 100644 js/modules/streamelements/module.js create mode 100644 js/modules/streamlabs/images/logo-streamlabs.svg create mode 100644 js/modules/streamlabs/module.css create mode 100644 js/modules/streamlabs/module.js create mode 100644 js/modules/tiktok/images/logo-tiktok.svg create mode 100644 js/modules/tiktok/module.css create mode 100644 js/modules/tiktok/module.js create mode 100644 js/modules/tipeeestream/images/logo-tipeeestream.svg create mode 100644 js/modules/tipeeestream/module.css create mode 100644 js/modules/tipeeestream/module.js create mode 100644 js/modules/twitch/images/logo-twitch.svg create mode 100644 js/modules/twitch/module.css create mode 100644 js/modules/twitch/module.js create mode 100644 js/modules/youtube/images/logo-youtube.svg create mode 100644 js/modules/youtube/module.css create mode 100644 js/modules/youtube/module.js create mode 100644 js/sb.js create mode 100644 js/speakerbot.js diff --git a/README.md b/README.md index a1d0ac2..6c04e14 100644 --- a/README.md +++ b/README.md @@ -1,37 +1,37 @@ # ![ChatRD](https://i.imgur.com/Ifpd7Ay.png) -ChatRD is a chat tool and/or overlay widget that unifies messages and events from **Twitch**, **YouTube**, **TikTok**, **Kick**, **Streamlabs**, **StreamElements**, **Patreon**, **TipeeeStream**, **Ko-Fi**, **Fourthwall** (and more to come). +ChatRD is a chat tool and/or overlay widget that unifies messages and events from **Twitch**, **YouTube**, **TikTok**, **Kick**, **Streamlabs**, **StreamElements**, **Patreon**, **TipeeeStream**, **Ko-Fi** and **Fourthwall**. -![ChatRD Config UI](https://i.imgur.com/zrP363q.png) +![ChatRD Config UI](https://i.imgur.com/ezrWaI2.png) ---- +## 🛠️ Setting it up -## 🚀 Features +Make sure your **Twitch**, **YouTube** and **Kick** accounts are connected on **Streamer.bot**. Also have **TikFinity Desktop App** installed and your account on **TikTok** setup. **BOTH APPS NEED TO RUN ON THE SAME PC**. -- 💬 **Multi-platform chat** -- 💬 **Multi-language events** -- 📊 **Events and Live stats** -- 🎨 **Customizable** -- 💾 **Saves your settings using localStorage and Streamer.Bot's Global Varaibles** +If you have both of these ready, follow these steps: ---- - -## 🛠️ Usage - -Make sure your **Twitch** and **YouTube** accounts are connected on **Streamer.Bot** and you have **TikFinity Desktop App** installed and set up to your account on **TikTok**. **BOTH APPS NEED TO RUN ON THE SAME PC**. - -### Streamer.Bot & TikFinity -1. On **Streamer.Bot**, go to **Server/Clients → WebSocket Server** and make sure it is running -2. Import the string inside the file [streamerbot-import.vortisrd](https://github.com/vortisrd/chatrd/blob/main/streamerbot-import.vortisrd) to your **Streamer.Bot** using the **Import** button at the top. -3. Go to **Server/Clients → WebSocket Client** and make sure the *TikFinity* WebSocket is connected. If not, right-click on it and check *Auto-Connect* and *Reconnect* before clicking on *Connect*. -4. Download [**TikFinity Desktop App**](https://tikfinity.zerody.one/) and make sure it's is opened and connected to your TikTok Account. +1. On **Streamer.bot**, import the file [chatrd.sb](https://github.com/vortisrd/chatrd/blob/main/chatrd.sb) to your **Streamer.bot**. +2. Go to **Server/Clients → WebSocket Server** and make sure it is running. 5. Open the [Settings Page](https://vortisrd.github.io/chatrd) in your browser. 6. Choose your desired options. 7. Click **"Copy URL"**. 8. Add the copied URL as a Browser Source in OBS. Or use it in your browser to read chat. 😊 9. For **Streamlabs**, **StreamElements**, **Patreon**, **TipeeeStream**, **Ko-Fi** and **Fourthwall**, you need to connect them to your Streamer.Bot account to their website. Follow the tutorial links in each section presented in the [Settings Page](https://vortisrd.github.io/chatrd). -### TikTok Chat Setup +--- + +## 🔊 Setting TTS with Speaker.Bot + +1. Go to **Settings → WebSocket Server**, click on *Start Server*. Make sure to also tick the *Auto-Start* checkbox. +2. Copy the IP and Port to ChatRD Speaker.bot fields. +3. Go to **Settings → Speech Engine** and add the TTS Service of your preference. (Sapi5 is the Windows default). +4. Go to **Settings → Voice Aliases**, give it a name and click **Add** right next to it. +5. In the Left Column, click on the **SpeakerBot** you just added and on the **Speak!** section, select the voice you want to use and click **Add**. (If you're using Sapi5, I recommend using *Microsoft Zira Desktop* as a voice). +6. Add the Alias name under the *Voice Alias* field on ChatRD. + +--- + +## 💬 Sending Messages to TikTok To send messages to **TikTok** using the *Chat Field*, you need to the following on **TikFinity**: 1. Make sure you're connected to your TikTok Account on **TikFinity**. If you're not, go to **Setup → TikTok Login** and click on *Login to TikTok*. @@ -43,52 +43,23 @@ To send messages to **TikTok** using the *Chat Field*, you need to the following ![Chatbot → Streamer.Bot Messages](https://i.imgur.com/IGQ5xQq.png) - -### Kick.Bot installation on Streamer.Bot - -1. First, [download Kick.Bot from Sehelitar's repo](https://github.com/Sehelitar/Kick.bot/releases/). -2. Unzip and copy the DLLs from **Kick.Bot** to the *dlls folder* inside **Streamer.Bot**. If it's not there, create one (name it "dlls", lowercase). -3. Import the action from *action.txt* file (inside the ZIP folder you just unzipped) to **Streamer.Bot**. -4. Close **Streamer.Bot** and open it again. After a few seconds, a window will appear asking you to login on *Kick*. -5. Done! 😊 - -If **Kick.Bot** stops sending events to ChatRD, delete it's dlls in the *dlls folder*, delete the imported **Kick.Bot** action and delete the *Streamer.bot.exe.WebView2* folder. After that, reinstall **Kick.Bot** using the above instructions. - - -#### ⚠️ KICK IS A BETA FEATURE! -Kick doesn't offer an API like Twitch does. It's not feasible for Streamer.Bot do it in an easy manner like Twitch, YouTube, Trovo, etc. - -[Kick.Bot](https://github.com/Sehelitar/Kick.bot/releases/) does an excelent job but there is some information missing on the payload from Kick, like *avatars*, *badges (gift sub, community, etc), viewership, etc*. And there will be some errors or some misinformation being shown. I tried my best to emulate these. - -I couldn't test every single outcome because it's not possible to simulate the events besides Chat. I followed [Kick.Bot's](https://github.com/Sehelitar/Kick.bot/releases/) documentation and hoped for the best. 🙏 - -Also, at any point either **Kick** or **Kick.Bot** might change their stuff, so please, **be patient!** 😊 - - ---- - -## 🔊 How to set TTS with Speaker.Bot - -### Speaker.Bot Setup -1. Go to **Settings → WebSocket Server**, click on *Start Server*. Make sure to also tick the *Auto-Start* checkbox. -2. Go to **Settings → Speech Engine** and add the TTS Service of your preference. (Sapi5 is the Windows default). -3. Go to **Settings → Voice Aliases**, name the voice *SpeakerBot* and click **Add** right next to it. -4. In the Left Column, click on the **SpeakerBot** you just added and on the **Speak!** section, select the voice you want to use and click **Add**. (If you're using Sapi5, I recommend using *Microsoft Zira Desktop* as a voice). - -### Streamer.Bot Setup -1. Import the [streamerbot-import.vortisrd](https://github.com/vortisrd/chatrd/blob/main/streamerbot-import.vortisrd) file to your **Streamer.Bot**. There's a new action that will handle the **Speaker.Bot** integration. -2. Go to **Integrations → Speaker.Bot**, click on *Connect*. Make sure to also tick the *Auto-Start* and *Auto-Connect* checkboxes. - --- ## 💻 Commands supported by the Chat Field **Commands for Twitch** - /me (message) +- /clip - /announce (message) +- /announceblue (message) +- /announcegreen (message) +- /announceorange (message) +- /announcepurple (message) - /clear - /slow (duration in seconds) - /slowoff +- /emoteonly +- /emoteonlyoff - /subscribers - /subscribersoff - /commercial (duration in seconds) @@ -103,80 +74,51 @@ Also, at any point either **Kick** or **Kick.Bot** might change their stuff, so - /shoutout (user) - /raid (user) - /unraid +- /settitle (stream title) +- /setgame (game name) **Commands for YouTube** -- /yt/title (title) -- /yt/description (description) +- /yt/title (stream title) - /yt/timeout (userID) (duration in seconds) - /yt/ban (userID) +**Commands for Kick** +- /kick/title (stream title) +- /kick/category (stream category title) +- /kick/timeout (user) (duration in seconds) +- /kick/untimeout (user) +- /kick/ban (user) (reason) +- /kick/unban (user) + **TikTok** -- TikTok commands are not supported yet. +- TikTok commands are not supported. -**Kick** -- Kick commands are not supported yet. - ---- - -## 📝 To-Do List - -- Trovo -- LivePix -- Tipa.Ai --- ## ❓ Frequently Asked Questions **- Can I use it to read my chat?** -R: Yes you can. You can open it on your browser, use it as a chat overlay and use it as a dock in OBS. +R: Yes you can. You can open it on your browser, use it as a chat overlay and/or use it as a dock in OBS. **- What about YouTube Members Emotes?** -R: YouTube doesn't expose their Partner Emojis to Streamer.Bot. All you can do is add the emojis manually in ChatRD. Please [read this](https://github.com/vortisrd/chatrd#about-youtube-membership-emojis). +R: YouTube doesn't expose their Membership/Partner Emojis to Streamer.bot. You would have to add them manually on ChatRD's Members Only Emotes section. + +What Casterlabs Caffeinated, Social Stream Ninja and Onecomme do to scrape the emotes won't work with the current way Streamer.Bot and my code works, so I had to choose between **making the user add them manually** or build a **server-sided executable (using NodeJS, Python or whatever) to read the chat as it's going or scrape the HTML code**. I don't want to add another executable on top of the user's flow, so it would be easier to use what it's currently available. **And no, I won't do any research based on what other tools do.** **- Can I set TTS to read only the events I want to read?** -R: ChatRD only reads either chats, events or both. If you want to filter the events, [I suggest you setup Speaker.Bot separately](https://github.com/vortisrd/chatrd?tab=readme-ov-file#about-speakerbot-tts-customization). +R: No. ChatRD sends either chats, events or both to Speaker.bot. **- TikTok events are not working anymore, what should I do?** -R: Make sure your TikFinity is connected to your account and you are live. Also go to **Server/Clients → WebSocket Client** and make sure the TikFinity WebSocket is connected. [Instructions here](https://github.com/vortisrd/chatrd#streamerbot--tikfinity). +R: Make sure your TikFinity is connected to your account and you are live. -**- Kick events are not working anymore, what should I do?** -R: Try reinstalling Kick.Bot [using the following methods](https://github.com/vortisrd/chatrd?tab=readme-ov-file#kickbot-installation-on-streamerbot). - -**- Where are my Kick sub badges? And the X or Y badges?** -R: At the moment of this post, the WebSocket I connect to show Kick's chat doesn't expose the Badges like Twitch does. Twitch mentions the badges and sends the URL, showing it [like this](https://i.imgur.com/xwg39hO.png). **Kick doesn't, showing on code [like this](https://i.imgur.com/OtMcDzI.png)**. You can see the person is subscribed and it's a sub gifter, but it doesn't show the specified badges. I did the best I could with the other badges. When Kick improves their API, I will revisit this later. +**- Kick events are not working or are taking too much time to show, what should I do?** +R: Kick's API is notoriously slow on their peak usage. It's been reported on [Streamer.bot](https://discord.streamer.bot/) Discord (check html-css-js section) that sometimes it could take up to 60 seconds for the responses to be relayed. I hope in the future they throw more money into their servers. **- Can you add other streaming/payment platforms?** -R: ChatRD uses Streamer.Bot to 95% of all platform iterations. *TikFinity* is perfectly integrated via WebSockets and *Kick.Bot* adds a decent integration. So if the platform has a decent WebSocket API (not WebHooks, those need a server to be usable) and/or has any integration with Streamer.Bot, please feel free to suggest it. Other than that, there are no plans to add more platforms. +R: ChatRD uses Streamer.Bot to 95% of all platform iterations. *TikFinity* is perfectly integrated via WebSockets. So if the platform has any integration with Streamer.bot or has a decent WebSocket API (not WebHooks), feel free to suggest it. Other than that, there are no plans to add more platforms. **- Can I customize it?** -R: If you mean visual styles, yes. [Read here](https://github.com/vortisrd/chatrd?tab=readme-ov-file#about-custom-styling). - - - ---- - -## **⚠️ DISCLAIMERS ⚠️** - -### About YouTube Membership Emojis -I tried to add member emotes but **that is currently impossible due to YouTube's API not exposing Members Emotes and with that, Streamer.Bot won't be able to show them.**. So I've added a way for the users to add them manually at the overlay, with the data saved as a Streamer.Bot Global Variable. - -What Casterlabs Caffeinated, Social Stream Ninja and Onecomme do to scrape the emotes won't work with the current way Streamer.Bot and my code works, so I had to choose between **making the user add them manually** or build a **server-sided executable (using NodeJS, Python or whatever) to read the chat as it's going or scrape the HTML code**. I don't want to add another executable on top of the user's flow, so it would be easier to use what it's currently available. **And no, I won't do any research based on what other tools do.** Tried to do that and wasted 1 week of my life doing it. - -When YouTube decide to expose their Partner Emotes on their API, I'll come back to this. - -### About Speaker.Bot TTS Customization -If you want to customize what events the TTS reads, like "I want it to read sub notifications but I don't want it to read bits", it's possible. But doing that means adding an extra TTS switch for every single event for all platforms, making the setup page almost triplicate in size and bloat the code. **I won't do that**. I want to keep it simple and contained. - -If you want to have TTS for events separately, I suggest you **Disable TTS Events** on ChatRD and setup Speaker.Bot with the things you want. 😊 - -### About Custom Styling -The safest way to customize ChatRD is open either the Chat or the Config in your browser and use [it's Dev Tools](https://i.imgur.com/Nirwz5R.png) to look for the tags, their classes, identifiers and then style in the way you want. **You need basic CSS knowledge for that**. - -After you finish it, paste the CSS inside the [Custom CSS field within Browser Source Property Window](https://i.imgur.com/BjvrV28.png). - -### About Support on Changing the Javascript or other Core Files -If you break it, you fix it. 😊 - +R: If you mean visual styles, you can add your own using the *Custom CSS* field in OBS's Browser Source Properties Window. You can use your browser Dev Tools to inspect the elements you want to change. **I won't provide support if you're planning to customize codes that could break ChatRD**. --- @@ -184,8 +126,8 @@ If you break it, you fix it. 😊 Made with ❤️ by **VortisRD** -🔗 [GitHub](https://github.com/vortisrd) • [Twitch](https://twitch.tv/vortisrd) • [YouTube](https://youtube.com/@vortisrd) • [TikTok](https://tiktok.com/@vortisrd) • [Kick](https://kick.com/vortisrd) • [Twitter / X](https://twitter.com/vortisrd) +🔗 [GitHub](https://github.com/vortisrd) • [Twitch](https://twitch.tv/vortisrd) • [YouTube](https://youtube.com/@vortisrd) • [Kick](https://kick.com/vortisrd) • [TikTok](https://tiktok.com/@vortisrd) • [Twitter / X](https://twitter.com/vortisrd) -Heavily inspired by [Nutty](https://nutty.gg) +Heavily inspired by [Nutty](https://nutty.gg). *Seriously, go give him some money!* -🔗 [GitHub](https://github.com/nuttylmao) • [Twitch](https://twitch.tv/nutty) • [YouTube](https://youtube.com/@nuttylmao) • [TikTok](https://tiktok.com/@nuttylmao) • [Twitter / X](https://x.com/nuttylmao) +🔗 [GitHub](https://github.com/nuttylmao) • [Twitch](https://twitch.tv/nutty) • [YouTube](https://youtube.com/@nuttylmao) • [Kick](https://kick.com/nutty) • [TikTok](https://tiktok.com/@nuttylmao) • [Twitter / X](https://x.com/nuttylmao) \ No newline at end of file diff --git a/chat.html b/chat.html index 4c5c515..61ec473 100644 --- a/chat.html +++ b/chat.html @@ -5,110 +5,128 @@ ChatRD - - - - + + + - + + + + + -
-
- - 0 -
- -
- - 0 - -
- -
- - 0 - -
- -
- - - - - 0 -
+ - -
- +
-
+
- + + + + + + + + + - + + + + + + - - - + + - + + - - + + - - - - - - - - + + + - - + + + + + + + + + + + + + + + + - + \ No newline at end of file diff --git a/chatrd.sb b/chatrd.sb new file mode 100644 index 0000000..db06687 --- /dev/null +++ b/chatrd.sb @@ -0,0 +1 @@ +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 \ No newline at end of file diff --git a/css/chatrd.css b/css/chatrd.css new file mode 100644 index 0000000..71ac1b3 --- /dev/null +++ b/css/chatrd.css @@ -0,0 +1,731 @@ +/* Basic reset and styling */ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +.sn-notify-outline { --sn-notify-background-color: #27272a; } +.sn-notify-outline .sn-notify-title { --sn-notify-title-color: #FFFFFF; } +.sn-notify-outline .sn-notify-text { --sn-notify-text-color: rgba(255,255,255,0.5); } + +.sn-notify-outline { --sn-notify-background-color: #27272a; } +.sn-notify-outline .sn-notify-title { --sn-notify-title-color: #FFFFFF; } +.sn-notify-outline .sn-notify-text { --sn-notify-text-color: rgba(255,255,255,0.5); } + +/* Full-page container styling */ +html { + width: 100vw; + height: 100vh; + scroll-behavior: smooth; + overflow-wrap: break-word; +} + + +body { + font-family: "DM Sans", sans-serif; + font-optical-sizing: auto; + height: 100%; + overflow: hidden; +} + + +#container { + height: 100%; + display: flex; + flex-direction: column; +} + +#container .wrapper { + flex: 1; + display: flex; + flex-direction: column; + overflow: hidden; +} + +#chat.noscrollbar { + overflow-y: hidden; +} + +#chat::-webkit-scrollbar { width: 8px; } +#chat::-webkit-scrollbar-track { background: #1e1e1e; } +#chat::-webkit-scrollbar-thumb { background-color: #555; border-radius: 4px; border: 2px solid #1e1e1e; } +#chat::-webkit-scrollbar-thumb:hover { background-color: #777; } + +#chat { + flex: 1; + display: flex; + flex-direction: column-reverse; + overflow-y: auto; + padding: 0px 10px; + scrollbar-color: #555 #1e1e1e; +} + +#chat.noscrollbar { + overflow-y: hidden; +} + +#chat .item { + position: relative; + color: #FFF; + font-size: 18px; + line-height: 150%; + text-shadow: 2px 2px 5px rgba(0,0,0,0.5); + transition: all ease-in-out 300ms; + /*margin: 5px 0;*/ +} + +#chat .item .message { + border-radius: 5px; + padding: 10px; + transition: all ease-in-out 300ms; +} + +#chat .item .info { + display: inline-flex; + align-items: center; + flex-wrap: wrap; + gap: 5px; +} + +#chat .item img { + height: 22px; + filter: drop-shadow(2px 2px 5px rgba(0,0,0,0.25)); +} + +#chat .item .platform, +#chat .item .badges { + height: 22px; +} + +#chat .item .platform .hidden-platform { + display: inline-block; + width: 12px; + height: 12px; + border-radius: 100px; + background: #FFF; +} + +#chat .item .badges img { + margin: 0 1px; +} + +#chat .item .info .avatar { + display: block; + width: 32px; + height: 32px; + overflow: hidden; + border-radius: 100px; +} + +#chat .item .info .avatar img { + height: 32px; +} + + +#chat .item .timestamp, +#chat .item .pronouns { + display: inline-block; + background: rgba(255,255,255,0.1); + padding: 0px 6px; + font-size: 12px; + border-radius: 5px; + font-weight: bold; +} + +#chat .item .pronouns em { + font-style: normal; +} + +#chat .item .first-message { + font-size: 14px; +} + +#chat .item .header span { + display: inline-block; + font-size: 14px; + margin-bottom: 10px; + background: rgba(255, 255, 255, 0.1); + padding: 5px 10px; + border-radius: 5px; +} + + +#chat .item .reply { + max-width: 100%; + flex-basis: 100%; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + display: block; + + font-size: 14px; + color: #999; +} + +#chat .item .reply i { + transform: rotate(90deg); + margin: 0 5px; +} + +#chat .item .shared-chat { + margin-bottom: 5px; +} +#chat .item .shared-chat span.origin { + display: inline-flex; + align-items: center; + background: rgba(255,255,255,0.1); + padding: 0px 10px; + border-radius: 5px; + font-weight: bold; + font-size: 14px; + gap: 5px; +} + +#chat .item .shared-chat img { + border-radius: 100px; +} + +#chat .item.first-chatter .message, +#chat .item.announcement .message { + display: inline-block; + background: rgba(255,255,255,0.2); + outline: 1px solid rgba(255,255,255,0.1); + outline-offset: -1px; + padding: 10px 15px; +} + +#chat .item .actual-message { + margin-top: 3px; + font-weight: 300; +} +#chat .item .actual-message img { + vertical-align: middle; + height: 28px; + margin: 1px; +} + + +#chat .event { + position: relative; + color: #FFF; + font-size: 18px; + line-height: 100%; + text-shadow: 2px 2px 5px rgba(0,0,0,0.5); + transition: all ease-in-out 300ms; + margin: 5px 10px; +} + +#chat .event .actual-message { + line-height: 150%; + font-weight: 300; +} + + +#chat .event .message { + display: inline-block; + background: rgba(255,255,255,0.2); + outline: 1px solid rgba(255,255,255,0.1); + outline-offset: -1px; + padding: 10px 20px 10px 15px; + border-radius: 5px; +} + + +#chat .event img { + height: 22px; + filter: drop-shadow(2px 2px 5px rgba(0,0,0,0.25)); +} + +#chat .event .platform { + height: 22px; +} + +#chat .event .info { + display: inline-flex; + align-items: center; + flex-wrap: wrap; + gap: 0 5px; +} + + + +#statistics { + width: 100%; + position: fixed; + top: 0; left: 0; + z-index: 11; + display: flex; + justify-content: flex-end; + gap: 5px; + padding: 10px 25px; + font-size: 12px; + font-weight: bold; + text-shadow: 2px 2px 5px rgba(0,0,0,0.5); +} + +#statistics .platform { + background: #000; + + outline: 1px solid rgba(255,255,255,0.25); + outline-offset: -1px; + + color: #FFF; + padding: 10px 15px 10px 10px; + border-radius: 5px; + + display: flex; + flex-direction: row; + align-items: center; + flex-wrap: nowrap; + gap: 5px; + + box-shadow: 2px 2px 5px rgba(0,0,0,0.15); +} + +#statistics .platform img { + width: 18px; + filter: drop-shadow(2px 2px 5px rgba(0,0,0,0.5)); +} + + +/* --------------------------- */ +/* ---- HORIZONTAL LAYOUT ---- */ +/* --------------------------- */ + +#chat.horizontal { + flex-direction: row-reverse; + align-items: flex-end; + gap: 5px; + padding: 0px 20px; + overflow-x: hidden; + overflow-y: unset; + white-space: nowrap; +} + +#chat.horizontal .item { + display: inline-flex; +} + +#chat.horizontal .item .message { + padding: 5px 10px; +} + + +#chat.horizontal .item .first-message::after, +#chat.horizontal .item .shared-chat::after { + content: ""; + display: block; +} + +#chat.horizontal .item .shared-chat::after { + margin-bottom: -5px; +} + +#chat.horizontal .item .first-message, +#chat.horizontal .item .shared-chat, +#chat.horizontal .item .header, +#chat.horizontal .item .info, +#chat.horizontal .item .actual-message { + display: inline !important; +} + +#chat.horizontal .item .reply { + display: inline-block; + max-width: 300px; + transform: translateY(8px); + padding: 0 3px 0 0; +} + +#chat.horizontal .item .info .timestamp { + transform: translateY(-3px); +} + +#chat.horizontal .item .info .platform, +#chat.horizontal .item .info .badges { + display: inline-block !important; + transform: translateY(4px); +} + +#chat.horizontal .item .avatar { + display: inline-block; + transform: translateY(10px); +} + +#chat.horizontal .item .info .pronouns { + transform: translateY(-3px); +} + +#chat.horizontal .event .info { + display: inline-block; +} + +#chat.horizontal .event .info .user, +#chat.horizontal .event .info .action, +#chat.horizontal .event .info .value { + display: inline-block; + transform: translateY(-5px); +} + +#chat.horizontal .item .platform .hidden-platform { + transform: translateY(-3px); +} + +#chat.horizontal .item.announcement .message { + padding: 10px 15px 0 15px; +} + +#chat.horizontal .item.announcement .message .info, +#chat.horizontal .item.announcement .message .actual-message { + display: inline-block !important; + transform: translateY(2px); +} + + + + + + + +/* --------------------------- */ +/* ----- ONE LINE LAYOUT ----- */ +/* --------------------------- */ + +#chat.oneline { + white-space: wrap; +} + +#chat.oneline .item { + margin: 0; +} + +#chat.oneline .item .message { + padding: 1px 10px; +} + +#chat.oneline .item .first-message::after, +#chat.oneline .item .shared-chat::after { + content: ""; + display: block; +} + +#chat.oneline .item .shared-chat::after { + margin-bottom: -5px; +} + +#chat.oneline .item .first-message, +#chat.oneline .item .shared-chat, +#chat.oneline .item .info, +#chat.oneline .item .actual-message { + display: inline !important; +} + +#chat.oneline .item .info .timestamp { + transform: translateY(-3px); +} + +#chat.oneline .item .info .platform, +#chat.oneline .item .info .badges { + display: inline-block !important; + transform: translateY(4px); +} + +#chat.oneline .item .platform .hidden-platform { + transform: translateY(-3px); +} + +#chat.oneline .item .avatar { + display: inline-block; + transform: translateY(10px); +} + +#chat.oneline .item .info .pronouns { + transform: translateY(-3px); +} + +#chat.oneline .event .info { + display: inline-block; +} + +#chat.oneline .event .info .user, +#chat.oneline .event .info .action, +#chat.oneline .event .info .value { + display: inline-block; + transform: translateY(-5px); +} + + + + + + +/* --------------------------- */ +/* ---- CHAT INPUT LAYOUT ---- */ +/* --------------------------- */ + +#chat-input { + padding: 15px 10px; + position: relative; +} + +#chat-input.enabled { + display: block; + width: 100%; +} + +#chat-input input[type=text] { + font-family: inherit; + border: none; + background: #222; + color: #FFF; + padding: 10px 50px 10px 15px; + border-radius: 5px; + outline: none; + font-size: 16px; + width: 100%; + transition: all ease-in-out 300ms; +} + +#chat-input button { + background: none; + border: none; + color: #FFF; + cursor: pointer; + font-size: 14px; + padding: 5px; + + position: absolute; + top: 50%; + transform: translateY(-50%); +} + +#chat-input button.active, +#chat-input button:hover { + color: #ffcc00; +} + +#chat-input #chat-input-send { + right: 20px; +} + +#chat-input .settings { + display: none; + background: #0c0c0c; + padding: 5px 10px; + border-radius: 100px; + + position: absolute; + top: -40px; + right: 0px; + z-index: 11; + + box-shadow: 0 0 10px rgba(255,255,255,0.05) +} + +#chat-input .settings.active { + display: inline-block; +} + +#chat-input .settings::after { + content: ""; + position: absolute; + bottom: -10px; /* posiciona fora do balão */ + right: 25px; /* onde a pontinha aparece horizontalmente */ + width: 0; + height: 0; + border-left: 10px solid transparent; + border-right: 10px solid transparent; + border-top: 10px solid #0c0c0c; /* mesma cor do balão */ +} + +#chat-input .settings img { + width: 20px; +} + + +#chat-input .chat-enabler { + display: inline-flex; + padding: 5px; + gap: 5px; + color: #FFF; + font-size: 14px; + align-items: center; + color: #333; +} + +#chat-input .settings .switch { + position: relative; + display: inline-block; + width: 40px; + height: 20px; +} + +#chat-input .settings .hint { + color: #FFF; + font-size: 18px; +} + +#chat-input .settings .hint a { + color: #ffcc00; + font-weight: bold; +} + +/* Hide default HTML checkbox */ +#chat-input .settings .switch input[type=checkbox] { + opacity: 1; + width: 0; + height: 0; +} + +/* The slider */ +#chat-input .settings .slider { + position: absolute; + cursor: pointer; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: #CCC; + transition: .4s; + border-radius: 30px; +} + +#chat-input .settings .slider:before { + position: absolute; + content: ""; + height: 16px; + width: 16px; + border-radius: 16px; + left: 3px; + top: 2px; + bottom: 0; + background-color: white; + transition: .4s; +} + +#chat-input .settings input[type=checkbox]:checked + .slider:before { + transform: translateX(19px); +} + +#chat-input #twitch input[type=checkbox]:checked + .slider { + background-color: #a970ff; +} +#chat-input #youtube input[type=checkbox]:checked + .slider { + background-color: #FF0000; +} +#chat-input #tiktok input[type=checkbox]:checked + .slider { + background-color: #ff0050; +} +#chat-input #kick input[type=checkbox]:checked + .slider { + background-color: #48d415; +} + + +#chat-input .settings input[type=checkbox]:checked + .slider { + background-color: #03c4de; +} +#chat-input .setting input[type=checkbox]:disabled + .slider { + background-color: #000 !important; +} + + + + +#chat-autocomplete-list { + position: absolute; + bottom: 65px; + left: 10px; + width: calc(100% - 40px); + border-radius: 5px; + background: #222; + color: #FFF; + + max-height: 300px; + overflow: hidden; + overflow-y: visible; + + font-size: 14px;; + + color: #696969; +} + +#chat-autocomplete-list::-webkit-scrollbar { width: 10px; } +#chat-autocomplete-list::-webkit-scrollbar-track { background: #1e1e1e; } +#chat-autocomplete-list::-webkit-scrollbar-thumb { background-color: #555; border-radius: 10px; border: 2px solid #1e1e1e; } +#chat-autocomplete-list::-webkit-scrollbar-thumb:hover { background-color: #777; } + +#chat-autocomplete-list div { + margin: 5px 10px; + padding: 5px; + border-radius: 5px; + cursor: pointer; +} + +#chat-autocomplete-list div.autocomplete-item { + cursor: pointer; +} + +#chat-autocomplete-list div:not(.autocomplete-item) { + background: #191919; + color: #FFF; + padding: 10px; + margin: 10px; +} + +#chat-autocomplete-list .autocomplete-item strong { + display: block; +} + +#chat-autocomplete-list .autocomplete-item:hover, +#chat-autocomplete-list .autocomplete-item.active { + color: #FFF; + background: #292929; +} + +#chat-autocomplete-list .autocomplete-item small { + color: #666; +} + + + +.item.chat .chatmoderation { + opacity: 0; + position: absolute; + top: 10px; + right: 10px; + transition: all ease-in-out 300ms; +} + +.item.chat:hover .message { + background: rgba(255,255,255,0.03); +} + +.item.chat:hover .chatmoderation { + opacity: 1; +} + +.item.chat .chatmoderation button { + background: none; + border: none; + color: #FFF; + background: rgba(255,255,255,0.1); + border-radius: 5px; + font-size: 14px; + padding: 5px; + transition: all ease-in-out 300ms; + margin: 0 2px; + cursor: pointer; +} + +.item.chat .chatmoderation button:hover { + color: #ffcc00; +} + + +.item.chat.twitch.streamer .chatmoderation button:nth-child(2), +.item.chat.twitch.streamer .chatmoderation button:nth-child(3), +.item.chat.kick.streamer .chatmoderation button:nth-child(1), +.item.chat.kick.streamer .chatmoderation button:nth-child(2), +.item.chat.youtube.owner .chatmoderation { + display: none; +} \ No newline at end of file diff --git a/css/settings.css b/css/settings.css index 18fe4a4..c8b9aa8 100644 --- a/css/settings.css +++ b/css/settings.css @@ -1,109 +1,316 @@ +/* Basic reset and styling */ * { margin: 0; padding: 0; box-sizing: border-box; } -body { - font-family: "Inter", sans-serif; +/* Full-page container styling */ +html { + width: 100vw; scroll-behavior: smooth; overflow-wrap: break-word; - background-color: #121212; - color: #FFF; - text-align: center; - font-size: 16px; } -a { color: #ffcc00; } +body { + font-family: "DM Sans", sans-serif; + font-optical-sizing: auto; + background-color: #121212; + overflow-x: hidden; +} + +a { color: #DBB048; } hr { border: 1px solid #222; margin: 20px 0; } -h2 { - font-size: 20px; - padding: 15px 30px; - border-bottom: 1px solid #222; - margin: 15px 0 0 0; - text-align: left; - position: relative; +#container { + width: 100vw; } -h2 i { - margin-right: 5px; +#container .wrapper { + display: flex; + flex-direction: row; + overflow-x: hidden; + width: 100vw; + /*height: 100vh;*/ } -h2 svg { - width: 26px; - vertical-align: bottom; - fill: #FFF; -} - -h2 img { - width: 26px; - vertical-align: bottom; -} - -h2 button { - display: none; - - position: absolute; - top: 50%; - right: 0; - transform: translateY(-50%); - - border: none; - background: black; - text-align: center; +#settings { + width: 640px; color: #FFF; - cursor: pointer; +} - font-size: 16px; - padding: 10px; +#settings form { + display: block; + padding: 20px; +} + +#settings .title { + text-align: center; + margin: 20px 0 0 0; +} + +#settings .title h1 { + background-image: url('../images/logo-chatrd.png'); + background-size: contain; + background-repeat: no-repeat; + text-indent: -9999px; + width: 200px; + height: 78px; + display: inline-block; +} + +#settings .platform { + background: #191919; + padding: 20px; + border-radius: 10px; + outline: 1px solid rgba(255,255,255,0.05); + outline-offset: -1px; + margin-bottom: 20px; +} + + +#settings .platform h2 { + display: inline-flex; + gap: 10px; +} + +#settings .platform h2 img { + height: 36px; +} + +#settings .platform .platform-enable { + display: inline-flex; + align-items: center; + gap: 10px; +} + +#settings .config { + display: flex; + justify-content: space-between; + padding: 5px 0px; + flex-direction: row; + flex-wrap: nowrap; + align-items: center; +} + +#settings .config strong { + font-weight: 900; +} + +#settings .config small { + color: #717171; +} + +#settings .config i.fa-solid.fa-arrow-turn-up { + transform: rotate(90deg); +} + +#settings .config input[type=color] { + background: #111; + padding: 0 2px; +} + + +#settings .config i { + font-size: 14px; + color: #666; +} + +#settings .config h2 i { + font-size: 24px; +} + + +#settings .switch { + position: relative; + display: inline-block; + width: 50px; + height: 27px; + text-align: center; +} + +#settings .switch input[type=checkbox] { + opacity: 1; + width: 0; + height: 0; +} + +#settings .config input[type=text], +#settings .config input[type=number], +#settings .config textarea { + font-family: "Inter", sans-serif; + border: none; + background:#222; + color: #FFF; + padding: 10px 15px; + border-radius: 10px; + outline: none; +} + +#settings .switch .slider { + position: absolute; + cursor: pointer; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: #CCC; + transition: .4s; + border-radius: 30px; +} + +#settings .switch .slider:before { + position: absolute; + content: ""; + height: 1.3em; + width: 1.2em; + border-radius: 16px; + left: 5px; + top: 3px; + bottom: 0; + background-color: white; + transition: .4s; +} + +#settings .switch input[type=checkbox]:checked + .slider:before { + transform: translateX(1.4em); +} + +#settings .switch input[type=checkbox]:checked + .slider { + background-color: #DBB048; +} + + + + +#settings .config .emote-list { + width:100%; + display: flex; + flex-wrap: wrap; /* allow items to wrap */ + gap: 10px; + margin-top: 15px; +} + +#settings .config .emote-list .emote-item { + width: 96px; + height: 96px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + position: relative; + background: #222; + border-radius: 10px; +} + +#settings .config .emote-list .emote-item button { + position: absolute; + top: 0; + right: 0; + width: 36px; + height: 36px; + + border-radius: 10px; + + transition: .4s; + + text-align: center; + background: none; + border: none; + cursor: pointer; +} + +#settings .config .emote-list .emote-item button.add { + top: 50%; + left: 50%; + right: unset; + + transform: translate(-50%,-50%); + + width: 100%; + height: 100%; + font-size: 36px; +} + +#settings .config .emote-list .emote-item button.add:hover { + background-color: #333; +} + +#settings .config .emote-list .emote-item button.add i { + vertical-align: middle; + font-size: 24px; +} + + +#settings .config .emote-list .emote-item em { + display: block; + margin-top: 5px; + font-size: 9px; +} + +#settings .config .emote-list .emote-item img { + height: 32px; +} + + +#settings small.warning { + display: inline-block; + padding: 10px 20px 10px 10px; + background-color: #232323; + color: #FFF; + border-radius: 10px; + line-height: 150%; +} + +#settings small.warning i { + color: #ffcc00; +} + + +#speakerbot .switch input[type=checkbox]:checked + .slider { background-color: #00dbff; } +#twitch .switch input[type=checkbox]:checked + .slider { background-color: #a970ff; } +#youtube .switch input[type=checkbox]:checked + .slider { background-color: #FF0000; } +#tiktok .switch input[type=checkbox]:checked + .slider { background-color: #ff0050; } +#kick .switch input[type=checkbox]:checked + .slider { background-color: #48d415; } +#streamelements .switch input[type=checkbox]:checked + .slider { background-color: #2700ff; } +#streamlabs .switch input[type=checkbox]:checked + .slider { background-color: #80f5d2; } +#patreon .switch input[type=checkbox]:checked + .slider { background-color: #ff5900; } +#tipeee .switch input[type=checkbox]:checked + .slider { background-color: #e02f44; } +#kofi .switch input[type=checkbox]:checked + .slider { background-color: #72a5f2; } +#fourthwall .switch input[type=checkbox]:checked + .slider { background-color: #0042ff; } + +.switch input[type=checkbox]:disabled + .slider { background-color: #111 !important; } + + + +#font-value, +#bg-opacity-value { + display: inline-block; + font-style: normal; + font-size: 14px; + width: 100%; + padding: 5px; + background: #121212; + text-align: center; border-radius: 100px; } -h2 button i { - margin: 0; - transition: transform 0.3s ease; +#preview { + width: calc(100% - 640px); + height: 100vh; + padding: 20px; } - -.accordion-container { - /*max-height: 0; - overflow: hidden;*/ - transition: max-height 0.4s ease; -} - - - -#chat-divided { - display: flex; - flex-direction: row; - flex-wrap: nowrap; - gap: 30px; - padding: 30px; -} - -#chat-settings { width: 640px; } -#chat-preview { width: calc(100% - 640px); } - -#chat-settings h1 { - width: 200px; - height: 80px; - background: url('../images/logo-chatrd.png') center center no-repeat; - background-size: cover; - margin: auto; - text-indent: -9999px; -} - -#chat-preview iframe { - position: sticky; - top: 30px; - left: 0; - width: 100%; - height: calc(100vh - 60px); +#preview iframe { + position: fixed; + top: 20px; + right: 0; + width: calc(100% - 640px); + height: calc(100vh - 40px); } .url-bar { @@ -135,300 +342,52 @@ h2 button i { background: #292929; } -.tab-content { - border-radius: 0 0 20px 20px; - background: #171717; - margin: 0 auto; - text-align: left; - margin: 0 0 30px 0; -} - -.tab-content .wrapper { - padding: 20px 30px; -} - -/* When visible */ -.tab-content.open { - display: block; -} - -.tab-content .setting { - display: flex; - justify-content: space-between; - padding: 5px 0px; - flex-direction: row; - flex-wrap: nowrap; - align-items: center; -} - -.tab-content .setting.column { - flex-direction: column; -} - -.tab-content .setting.column label { - width: 100%; -} - -.tab-content .setting.column .emote-list { - width:100%; - display: flex; - flex-wrap: wrap; /* allow items to wrap */ - gap: 10px; - margin-top: 15px; -} - -.tab-content .setting.column .emote-list .emote-item { - width: 96px; - height: 96px; - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - position: relative; - background: #222; - border-radius: 10px; -} - -.tab-content .setting.column .emote-list .emote-item button { - position: absolute; - top: 0; - right: 0; - width: 36px; - height: 36px; - - border-radius: 10px; - - transition: .4s; - - text-align: center; - background: none; - border: none; - cursor: pointer; -} - -.tab-content .setting.column .emote-list .emote-item button.add { - top: 50%; - left: 50%; - right: unset; - - transform: translate(-50%,-50%); - - width: 100%; - height: 100%; - font-size: 36px; -} - -.tab-content .setting.column .emote-list .emote-item button.add:hover { - background-color: #333; -} - -.tab-content .setting.column .emote-list .emote-item button.add i { - vertical-align: middle; -} - - -.tab-content .setting.column .emote-list .emote-item em { - display: block; - margin-top: 5px; - font-size: 9px; -} - -.tab-content .setting.column .emote-list .emote-item img { - height: 24px; -} - - -.tab-content .setting i { - font-size: 14px; - color: #666; - margin: 0 10px; -} - -.tab-content .setting i.fa-arrow-turn-up { - transform: rotate(90deg); -} - - -.tab-content .setting small { color: #777; } - -.tab-content .setting input[type=text], -.tab-content .setting input[type=number], -.tab-content .setting textarea { - font-family: "Inter", sans-serif; - border: none; - background:#222; - color: #FFF; - padding: 10px 20px; - border-radius: 10px; - outline: none; -} - -.tab-content .setting textarea { - width: 100%; - height: 200px; - margin-top: 15px; -} - -.tab-content .setting.select select { - font-family: "Inter", sans-serif; - border: none; - background:#222; - color: #FFF; - padding: 10px 20px 10px 10px; - border-radius: 10px; - outline: none; -} - -.tab-content .setting .switch { - position: relative; - display: inline-block; - width: 50px; - height: 27px; -} - -/* Hide default HTML checkbox */ -.tab-content .setting .switch input[type=checkbox] { - opacity: 1; - width: 0; - height: 0; -} - -/* The slider */ -.tab-content .setting .slider { - position: absolute; - cursor: pointer; - top: 0; - left: 0; - right: 0; - bottom: 0; - background: #CCC; - transition: .4s; - border-radius: 30px; -} - -.tab-content .setting .slider:before { - position: absolute; - content: ""; - height: 1.3em; - width: 1.2em; - border-radius: 16px; - left: 5px; - top: 3px; - bottom: 0; - background-color: white; - transition: .4s; -} - - - -.tab-content .setting input[type=color] { - background: #111; - padding: 0 2px; -} - -.tab-content .setting input[type=checkbox]:checked + .slider { - background-color: #03c4de; -} - -.tab-content .setting input[type=checkbox]:checked + .slider:before { - transform: translateX(1.4em); -} - -.tab-content#twitch .setting input[type=checkbox]:checked + .slider { - background-color: #a970ff; -} -.tab-content#youtube .setting input[type=checkbox]:checked + .slider { - background-color: #FF0000; -} -.tab-content#tiktok .setting input[type=checkbox]:checked + .slider { - background-color: #ff0050; -} - -.tab-content#kick .setting input[type=checkbox]:checked + .slider { - background-color: #48d415; -} - -.tab-content#extras .setting input[type=checkbox]:checked + .slider { - background-color: #00dd63; -} - -.tab-content#patreon .setting input[type=checkbox]:checked + .slider { - background-color: #ff5900; -} - -.tab-content#tipeee .setting input[type=checkbox]:checked + .slider { - background-color: #e02f44; -} - -.tab-content#kofi .setting input[type=checkbox]:checked + .slider { - background-color: #72a5f2; -} - -.tab-content#fourthwall .setting input[type=checkbox]:checked + .slider { - background-color: #0042ff; -} - -.tab-content .setting input[type=checkbox]:disabled + .slider { - background-color: #000 !important; -} - - - - -.slider-value { - display: inline-block; - font-style: normal; - font-size: 14px; - width: 100%; - padding: 5px; - background: #121212; - text-align: center; - border-radius: 100px; -} - footer { + width: 640px; position: sticky; bottom: 0; + left: 0; padding: 20px; background: rgba(18,18,18,0.5); backdrop-filter: blur(10px); - margin-top: 80px; font-size: 14px; + text-align: center; + color: #FFF; } footer a { display: inline-block; margin: 10px 5px; font-size: 20px; - color: #ffcc00; -} - -footer a svg { - width: 24px; - vertical-align: bottom; - fill: #ffcc00; + color: #DBB048; } footer a img { - width: 24px; + height: 22px; vertical-align: bottom; } + +footer a[href="https://kick.com/vortisrd"] svg { + height: 26px; + vertical-align: bottom; + fill: #DBB048; +} + footer .nav-bar { display: inline-flex; flex-direction: row; align-items: center; justify-content: center; font-size: 12px; - width: 100%; } footer .nav-bar a { display: inline-block; border-radius: 100px; - padding: 10px 15px; + padding: 10px; margin: 0; font-size: 16px; text-decoration: none; @@ -437,58 +396,14 @@ footer .nav-bar a { } footer .nav-bar a:hover { - color: #ffcc00; + color: #DBB048; background: rgba(0,0,0,0.40); } -footer .nav-bar a svg { - width: 20px; - fill: #FFF; - transition: all ease-in-out 300ms; +footer .nav-bar a img { + height: 24px; } -footer .nav-bar a:hover svg { - fill: #ffcc00; -} - - -footer .nav-bar a.active { - color: #ffcc00; - background: rgba(0,0,0,0.20); -} - -footer .nav-bar a.active svg { - fill: #ffcc00; -} - -@media only screen and (max-width: 768px) { - - #chat-divided { - flex-direction: column; - } - - #chat-settings { - width: 100%; - } - - #chat-settings .field { - width: 100%; - display: block; - } - - #chat-settings .field input { - width: 100%; - margin: 10px 0; - } - - #chat-preview { - display: none; - } - - footer { font-size: 12px; } -} - - .modal { position: fixed; top: 0; left: 0; @@ -498,6 +413,11 @@ footer .nav-bar a.active svg { align-items: center; justify-content: center; z-index: 999; + color: #FFF; +} + +.modal small { + color: #717171; } .modal.hidden { @@ -570,11 +490,18 @@ footer .nav-bar a.active svg { } -#memberemotesbstatus.online i { - color: #00dd63; - text-shadow: 0 0 5px #00dd63; +#settings .config #streamerBotStatus label i, +#settings .config #speakerBotStatus label i, +#settings .config #tikfinityStatus label i, +#settings .config #kickStatus label i { + color: #ce2c2c; + text-shadow: 0 0 5px #ce2c2c; } -#memberemotesbstatus.offline i { - color: #ff0000; - text-shadow: 0 0 5px #ff0000; + +#settings .config #streamerBotStatus.connected label i, +#settings .config #speakerBotStatus.connected label i, +#settings .config #tikfinityStatus.connected label i, +#settings .config #kickStatus.connected label i { + color: #14c22b; + text-shadow: 0 0 5px #14c22b; } \ No newline at end of file diff --git a/images/img-empty.png b/images/img-empty.png new file mode 100644 index 0000000000000000000000000000000000000000..be3e7ea75493ca843c385c53fe8f5d37d03b2b0f GIT binary patch literal 7419 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 D@S|a+ literal 0 HcmV?d00001 diff --git a/images/logo-speakerbot.png b/images/logo-speakerbot.png new file mode 100644 index 0000000000000000000000000000000000000000..78f7d2f7c9049d3769c96900cc9f956a3c1925f4 GIT binary patch literal 21931 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 H_tyUbeQ0lo literal 0 HcmV?d00001 diff --git a/images/logo-streamerbot.png b/images/logo-streamerbot.png new file mode 100644 index 0000000000000000000000000000000000000000..f084c45e7b12258f14b2287b11f184854d688975 GIT binary patch literal 30881 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 literal 0 HcmV?d00001 diff --git a/index.html b/index.html index 9852b55..8aa6543 100644 --- a/index.html +++ b/index.html @@ -1,406 +1,873 @@ + - ChatRD - Config + ChatRD - - - - - - + + + + + + + + +
+
+
+

ChatRD

+ + +
+ + +
+ You can find the documentation and how to setup ChatRD right here. +
+ + +
+ +
+

Streamer.bot

+ +
+ Awaiting for connection + +
+
+ +
+
+ + +
+
+ + +
+
-
-
-

ChatRD - Setup

- -

Config

- -
-
-
- - - Streamer.Bot is Offline! - -
- -
-
- -
- -
Speaker.Bot is required for Text-to-Speech! Follow the tutorial.
- -
- -
- -
-
-
- - - - - -

General

-
-
-
- - -
- -
- - -
- -
- -
- -
- -
- -
The Chat Field is a BETA feature!
- -
- -
- -
The Chat Field also accepts /commands. Commands Supported.
- -
- -
Moderation is a BETA feature!
- -
- -
- -
Moderation Actions follows the Commands suported. Read More.
- -
- -
- - -
- -
- - -
- -
-
-
-
-
-
-
-
-
-
-
-
-
- - - - - -

Twitch

-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- - - - - -

YouTube

-
-
-
-
-
- -
- -
- -
-
-
-
- -
- -
YouTube API doesn't expose Member Emotes. Read more.
- -
- - -
- -
- - - - -
- - - -
-
-
- - - - - -

TikTok

-
-
-
-
-
-
-
-
-
-
- - - - - -

- - - - Kick

-
-
- -
Kick is a BETA feature! Read more.
-
- -
- -
- -
+
+ + + + +
+ +
+

Speaker.bot

+ +
+ + +
+
+ + + +
+ + + + + +
+
+

Setup

+
+ +
+ +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ + +
+ + +
+ + +
+ + +
+ +
+ + +
+ +
+ + + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ +
+ + +
+ +
+ + +
+ +
+ + The Chat Field also accepts /commands. Commands Supported. + +
+ +
+
+ + + + +
+ +
+

Twitch

+ +
+ + +
+
+ + + + +
+ + + + + + + +
+ +
+

YouTube

+ +
+ + +
+
+ + + + +
+ + + + + + + +
+ +
+

TikTok

+ +
+ + +
+
+ + + + +
-
- -
Kick.Bot is required for events. Follow the tutorial.
-
-
-
-
-
-
-
-
- - -

Donations

-
-
-
You need to connect Streamlabs to Streamer.Bot. Follow the tutorial.
-
+
-
You need to connect StreamElements to Streamer.Bot. Follow the tutorial.
+
+

Kick

-
+
+ + +
+
-
-
-
-
+ +
- - - -

TipeeeStream

-
-
-
You need to connect TipeeeStream to Streamer.Bot. Follow the tutorial.
-
-
-
-
- - - -

Ko-Fi

-
-
-
You need to connect Ko-Fi to Streamer.Bot. Follow the tutorial.
- -
-
-
-
-
-
+
+
+

StreamElements

- - - -

Fourthwall

-
-
-
You need to connect Fourthwall to Streamer.Bot. Follow the tutorial.
+
+ + +
+
-
+ -
- -
-
+
-
-
-
-
-
-
-
-
-
-
- - -
- - -
- -

Made with ❤️ by VortisRD

-

- - - - - - - - - - -

-
-
- -
- -
-
+ +
+ +
+

Streamlabs

+ +
+ + +
+
+ + + + +
+ + + + + + + +
+ +
+

Patreon

+ +
+ + +
+
+ + + + +
+ + + + + + + +
+ +
+

TipeeeStream

+ +
+ + +
+
+ + + + +
+ + + + + + + +
+ +
+

Ko-fi

+ +
+ + +
+
+ + + + +
+ + + + + + + +
+ +
+

Fourthwall

+ +
+ + +
+
+ + + + +
+ + + + + + + +
+ + +
+
+ +
+
+ + + + + + +
+ + +
+ + +
+ +

Made with ❤️ by VortisRD

+

+ + + + + + + + + + +

+
+
+ + - - - - + + + + + + - + \ No newline at end of file diff --git a/js/chatrd.js b/js/chatrd.js new file mode 100644 index 0000000..8568280 --- /dev/null +++ b/js/chatrd.js @@ -0,0 +1,680 @@ +/* ----------------------- */ +/* OPTIONS */ +/* ----------------------- */ + +const showPlatform = getURLParam("showPlatform", true); +const showPlatformDot = getURLParam("showPlatformDot", false); +const showAvatar = getURLParam("showAvatar", true); +const showTimestamps = getURLParam("showTimestamps", true); +const ampmTimeStamps = getURLParam("ampmTimeStamps", false); +const showBadges = getURLParam("showBadges", true); +const showPlatformStatistics = getURLParam("showPlatformStatistics", true); + +const chatThreshold = 50; +const chatOneLine = getURLParam("chatOneLine", false); +const chatHorizontal = getURLParam("chatHorizontal", false); +const chatFontSize = getURLParam("chatFontSize", 1); +const chatBackground = getURLParam("chatBackground", "#121212"); +const chatBackgroundOpacity = getURLParam("chatBackgroundOpacity", 1); +const chatScrollBar = getURLParam("chatScrollBar", false); +const chatField = getURLParam("chatField", false); +const chatModeration = getURLParam("chatModeration", false); + +const excludeCommands = getURLParam("excludeCommands", true); +const ignoreChatters = getURLParam("ignoreChatters", ""); +const ignoreUserList = ignoreChatters.split(',').map(item => item.trim().toLowerCase()) || []; + +const hideAfter = getURLParam("hideAfter", 0); + +const chatContainer = document.querySelector('#chat'); +const chatTemplate = document.querySelector('#chat-message'); +const eventTemplate = document.querySelector('#event-message'); + +const userColors = new Map(); + + +if (showPlatformStatistics == true) { document.querySelector('#statistics').style.display = ''; } +if (chatScrollBar == false) { chatContainer.classList.add('noscrollbar'); } +if (chatOneLine == true && !chatHorizontal) { chatContainer.classList.add('oneline'); } +if (chatHorizontal == true) { + chatContainer.classList.remove('oneline'); + chatContainer.classList.add('horizontal'); +} + +let backgroundColor = hexToRGBA(chatBackground,chatBackgroundOpacity); +chatContainer.parentElement.style.backgroundColor = backgroundColor; + +chatContainer.style.zoom = chatFontSize; + +if (chatField) { + const chatfieldelement = document.getElementById("chat-input"); + chatfieldelement.style.display = ''; +} + + +function addMessageItem(platform, clone, classes, userid, messageid) { + removeExtraChatMessages(); + + let chatmodtwitch = `
+ + + +
`; + + let chatmodyoutube = `
+ + +
`; + + let chatmodkick = `
+ + +
`; + + if (showSpeakerbot == true && speakerBotChatRead == true) { speakerBotTTSRead(clone, 'chat'); } + + const root = clone.firstElementChild; + root.classList.add(...classes); + root.dataset.user = userid; + root.id = messageid; + root.style.opacity = '0'; + + const platformElement = clone.querySelector('.platform'); + + if (showPlatform == true) { + platformElement.innerHTML = ``; + } + + if (showPlatformDot == true) { + platformElement.innerHTML = ``; + } + + if (showPlatform == false && showPlatformDot == false) { + platformElement.remove(); + } + + const timestamp = clone.querySelector('.timestamp'); + if (timestamp) { + if (showTimestamps) { + timestamp.textContent = whatTimeIsIt(); + } else { + timestamp.remove(); + } + } + + const dimensionProp = chatHorizontal ? 'Width' : 'Height'; + + // Starts it collapsed + root.style[dimensionProp.toLowerCase()] = '0px'; + + if (chatModeration == true) { + switch (platform) { + case "twitch": + root.insertAdjacentHTML("beforeend", chatmodtwitch); + break; + + case "youtube": + root.insertAdjacentHTML("beforeend", chatmodyoutube); + break; + + case "kick": + root.insertAdjacentHTML("beforeend", chatmodkick); + break; + + default: + console.warn(`Plataforma desconhecida: ${platform}`); + } + } + + chatContainer.prepend(clone); + + const item = document.getElementById(messageid); + const itemDimension = item.querySelector('.message')?.[`offset${dimensionProp}`] || 0; + + // Animates the item + requestAnimationFrame(() => { + item.style[dimensionProp.toLowerCase()] = itemDimension + 'px'; + item.style.opacity = '1'; + }); + + item.addEventListener('transitionend', () => { + item.style[dimensionProp.toLowerCase()] = ''; + item.style.opacity = ''; + }, { once: true }); + + // Hides it after a while + if (hideAfter > 0) { + setTimeout(() => { + item.style.opacity = '0'; + setTimeout(() => { + item.remove(); + }, 1000); + }, Math.floor(hideAfter * 1000)); + } +} + + +function addEventItem(platform, clone, classes, userid, messageid) { + removeExtraChatMessages(); + + if (showSpeakerbot == true && speakerBotEventRead == true) { speakerBotTTSRead(clone, 'event'); } + + const root = clone.firstElementChild; + root.classList.add(...classes); + root.dataset.user = userid; + root.id = messageid; + root.style.opacity = '0'; + + const platformElement = clone.querySelector('.platform'); + + if (showPlatform == true) { + platformElement.innerHTML = ``; + } + + if (showPlatformDot == true) { + platformElement.innerHTML = ``; + } + + if (showPlatform == false && showPlatformDot == false) { + platformElement.remove(); + } + + const timestamp = clone.querySelector('.timestamp'); + if (timestamp) { + if (showTimestamps) { + timestamp.textContent = whatTimeIsIt(); + } else { + timestamp.remove(); + } + } + + const dimensionProp = chatHorizontal ? 'Width' : 'Height'; + + // Starts it collapsed + root.style[dimensionProp.toLowerCase()] = '0px'; + + chatContainer.prepend(clone); + + const item = document.getElementById(messageid); + const itemDimension = item.querySelector('.message')?.[`offset${dimensionProp}`] || 0; + + // Animates the item + requestAnimationFrame(() => { + item.style[dimensionProp.toLowerCase()] = itemDimension + 'px'; + item.style.opacity = '1'; + }); + + item.addEventListener('transitionend', () => { + item.style[dimensionProp.toLowerCase()] = ''; + item.style.opacity = ''; + }, { once: true }); + + // Hides it after a while + if (hideAfter > 0) { + setTimeout(() => { + item.style.opacity = '0'; + setTimeout(() => { + item.remove(); + }, 1000); + }, Math.floor(hideAfter * 1000)); + } +} + + +function removeItem(element) { + element.remove(); +} + + +function removeExtraChatMessages() { + const chatMessages = chatContainer.querySelectorAll(':scope > div'); + const total = chatMessages.length; + + if (total >= chatThreshold) { + const toRemove = Math.floor(total * 0.25); // 25% do total + for (let i = 0; i < toRemove; i++) { + const last = chatContainer.lastElementChild; + if (last) chatContainer.removeChild(last); + } + } +} + + +function whatTimeIsIt() { + const now = new Date(); + const hours24 = now.getHours(); + const minutes = now.getMinutes().toString().padStart(2, '0'); + const ampm = hours24 >= 12 ? 'PM' : 'AM'; + const hours12 = (hours24 % 12) || 12; + + if (ampmTimeStamps === true) { + return `${hours12}:${minutes} ${ampm}`; + } else { + return `${hours24}:${minutes}`; + } +} + +// Function to format large numbers (e.g., 1000 => '1K') +function formatNumber(num) { + if (num >= 1000000) { + let numStr = (num / 1000000).toFixed(1); + if (numStr.endsWith('.0')) { + numStr = numStr.slice(0, -2); + } + return numStr + 'M'; + } + else if (num >= 1000) { + let numStr = (num / 1000).toFixed(1); + if (numStr.endsWith('.0')) { + numStr = numStr.slice(0, -2); + } + return numStr + 'K'; + } + return num.toString(); +} + + +function formatCurrency(amount, currencyCode) { + if (!currencyCode) { currencyCode = 'USD'; } + + return new Intl.NumberFormat(undefined, { + style: 'currency', + currency: currencyCode, + minimumFractionDigits: 0, + maximumFractionDigits: 2 + }).format(amount); +} + + +function createRandomString(length) { + const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; + let result = ""; + for (let i = 0; i < length; i++) { + result += chars.charAt(Math.floor(Math.random() * chars.length)); + } + return result; +} + + +function createRandomColor(platform, username) { + if (userColors.get(platform).has(username)) { + return userColors.get(platform).get(username); + } + else { + const randomColor = "hsl(" + Math.random() * 360 + ", 100%, 75%)"; + userColors.get(platform).set(username, randomColor); + return randomColor; + } +} + + +function hexToRGBA(hexadecimal,opacity) { + const hex = hexadecimal; + const alpha = parseFloat(opacity); + + // Converter hex para RGB + const r = parseInt(hex.substr(1, 2), 16); + const g = parseInt(hex.substr(3, 2), 16); + const b = parseInt(hex.substr(5, 2), 16); + + return `rgba(${r}, ${g}, ${b}, ${alpha})`; +} + + +function stripStringFromHtml(html) { + let doc = new DOMParser().parseFromString(html, 'text/html'); + return doc.body.textContent || ""; +} + + +async function cleanStringOfHTMLButEmotes(string) { + // Cria um elemento DOM temporário + const container = document.createElement('div'); + container.innerHTML = string; + + // Substitui ... por texto do alt + const emotes = container.querySelectorAll('img.emote[alt]'); + emotes.forEach(img => { + const altText = img.getAttribute('alt'); + const textNode = document.createTextNode(altText); + img.replaceWith(textNode); + }); + + // Remove todo o restante do HTML + return container.textContent || ""; +} + + +const pushNotify = (data) => { + + const SimpleNotify = { + effect: 'fade', + speed: 500, + customClass: 'toasty', + customIcon: '', + showIcon: true, + showCloseButton: true, + autoclose: true, + autotimeout: 5000, + notificationsGap: null, + notificationsPadding: null, + type: 'outline', + position: 'x-center bottom', + customWrapper: '', + }; + const mergedData = { + ...SimpleNotify, + ...data + } + new Notify (mergedData); + +} + +const notifyError = (err) => { + err.status = 'error'; + pushNotify(err); +} + +const notifyInfo = (info) => { + info.status = 'info'; + pushNotify(info); +} + +const notifyWarning = (warn) => { + warn.status = 'warning'; + pushNotify(warn); +} + + +const notifySuccess = (success) => { + success.status = 'success'; + pushNotify(success); +} + + + + + + +/* -------------------------- */ +/* ---- CHAT INPUT UTILS ---- */ +/* -------------------------- */ + +const chatcommandslist = document.getElementById('chat-autocomplete-list'); +let chatcurrentFocus = -1; + +const chatInputSend = document.getElementById("chat-input-send"); +const chatInputForm = document.querySelector("#chat-input form"); +const chatInput = chatInputForm.querySelector("input[type=text]") + +let chatcommands = { + "Twitch" : [ + { "name" : "/me", "usage" : "Creates a colored message. Usage: /me [message]" }, + { "name" : "/clip", "usage" : "Creates a 30s clip. Usage: /clip" }, + { "name" : "/announce", "usage" : "Sends an announcement. Usage: /announce [message]" }, + { "name" : "/announceblue", "usage" : "Sends an announcement in blue. Usage: /announceblue [message]" }, + { "name" : "/announcegreen", "usage" : "Sends an announcement in green. Usage: /announcegreen [message]" }, + { "name" : "/announceorange", "usage" : "Sends an announcement in orange. Usage: /announceorange [message]" }, + { "name" : "/announcepurple", "usage" : "Sends an announcement in purple. Usage: /announcepurple [message]" }, + { "name" : "/clear", "usage" : "Clear Chat Messages. Usage: /clear" }, + { "name" : "/slow", "usage" : "Activates slow mode. Usage: /slow [duration]" }, + { "name" : "/slowoff", "usage" : "Deactivates slow mode. Usage: /slowoff" }, + { "name" : "/subscribers", "usage" : "Activates subscribers only mode. Usage: /subscribers" }, + { "name" : "/subscribersoff", "usage" : "Deactivates subscribers only mode. Usage: /subscribersoff" }, + { "name" : "/emoteonly", "usage" : "Activates emote only mode. Usage: /emoteonly" }, + { "name" : "/emoteonlyoff", "usage" : "Deactivates emote only mode. Usage: /emoteonlyoff" }, + { "name" : "/commercial", "usage" : "Add a commercial break. Usage: /commercial [duration]" }, + { "name" : "/timeout", "usage" : "Timeouts a user. Usage: /timeout [user] [duration] [reason]" }, + { "name" : "/untimeout", "usage" : "Removes timeout from a user. Usage: /untimeout [user]" }, + { "name" : "/ban", "usage" : "Bans a user. Usage: /ban [user] [reason]" }, + { "name" : "/unban", "usage" : "Unbans a user. Usage: /unban [user]" }, + { "name" : "/mod", "usage" : "Mod a user. Usage: /mod [user]" }, + { "name" : "/unmod", "usage" : "Removes mod from a user. Usage: /unmod [user]" }, + { "name" : "/vip", "usage" : "Adds user to VIP. Usage: /vip [user]" }, + { "name" : "/unvip", "usage" : "Removes user from VIP. Usage: /unvip [user]" }, + { "name" : "/shoutout", "usage" : "Shoutouts a user. Usage: /shoutout [user]" }, + { "name" : "/raid", "usage" : "Raids a user. Usage: /raid [user]" }, + { "name" : "/unraid", "usage" : "Removes the outcoming raid. Usage: /unraid" }, + { "name" : "/settitle", "usage" : "Sets the stream title. Usage: /settitle [title]" }, + { "name" : "/setgame", "usage" : "Sets the stream game. Usage: /setgame [game]" }, + ], + "YouTube" : [ + { "name" : "/yt/title", "usage" : "Sets the stream title. Usage: /yt/title [title]" }, + { "name" : "/yt/timeout", "usage" : "Times out a user. Usage: /yt/timeout [user] [duration]" }, + { "name" : "/yt/ban", "usage" : "Bans a user. Usage: /yt/ban [user]" } + ], + "Kick" : [ + { "name" : "/kick/title", "usage" : "Sets the stream title. Usage: /kick/title [title]" }, + { "name" : "/kick/category", "usage" : "Sets the stream category. Usage: /kick/category [category]" }, + { "name" : "/kick/timeout", "usage" : "Times out a user. Usage: /kick/timeout [user] [duration]" }, + { "name" : "/kick/untimeout", "usage" : "Removes timeout from a user. Usage: /kick/untimeout [user]" }, + { "name" : "/kick/ban", "usage" : "Bans a user. Usage: /kick/ban [user]" }, + { "name" : "/kick/unban", "usage" : "Unbans a user. Usage: /kick/unban [user]" } + ] +}; + + + +function highlightItem(items) { + if (!items) return; + + items.forEach(item => item.classList.remove('active')); + + if (chatcurrentFocus >= items.length) chatcurrentFocus = 0; + if (chatcurrentFocus < 0) chatcurrentFocus = items.length - 1; + + items[chatcurrentFocus].classList.add('active'); + items[chatcurrentFocus].scrollIntoView({ block: "nearest" }); +} + + + + +chatInput.addEventListener('input', function () { + const value = this.value.trim(); + chatcommandslist.innerHTML = ''; + chatcurrentFocus = -1; + if (!value.startsWith('/')) return; + Object.entries(chatcommands).forEach(([groupName, commands]) => { + + const filtered = commands.filter(cmd => cmd.name.startsWith(value)); + + if (filtered.length === 0) return; + + const groupTitle = document.createElement('div'); + groupTitle.textContent = groupName; + chatcommandslist.appendChild(groupTitle); + filtered.forEach(cmd => { + const item = document.createElement('div'); + item.classList.add('autocomplete-item'); + item.innerHTML = `${cmd.name} ${cmd.usage}`; + item.addEventListener('click', () => { + chatInput.value = cmd.name + ' '; + chatcommandslist.innerHTML = ''; + }); + chatcommandslist.appendChild(item); + }); + }); +}); + +chatInput.addEventListener('keydown', function (e) { + const items = chatcommandslist.querySelectorAll('.autocomplete-item'); + + if (items.length === 0) return; + + if (e.key === 'ArrowDown') { + chatcurrentFocus++; + highlightItem(items); + } + else if (e.key === 'ArrowUp') { + chatcurrentFocus--; + highlightItem(items); + } + + else if (e.key === 'Enter') { + e.preventDefault(); + if (chatcurrentFocus > -1 && items[chatcurrentFocus]) { + items[chatcurrentFocus].click(); + } + } +}); + + + + +chatInputForm.addEventListener("submit", function(event) { + event.preventDefault(); + + var chatSendPlatforms = []; + + if (showTwitch == true && showTwitchMessages == true) { chatSendPlatforms.push('twitch'); } + if (showYoutube == true && showYouTubeMessages == true) { chatSendPlatforms.push('youtube'); } + if (showTiktok == true && showTikTokMessages == true) { chatSendPlatforms.push('tiktok'); } + if (showKick == true && showKickMessages == true) { chatSendPlatforms.push('kick'); } + + chatSendPlatforms = chatSendPlatforms.join(',') + + const chatInput = chatInputForm.querySelector("input[type=text]") + const chatInputText = chatInput.value; + + // Sends Message to Twitch and YouTube + streamerBotClient.doAction( + { name : "[Twitch][YouTube][Kick] Msgs/Cmds" }, + { + "type": "chat", + "platforms": chatSendPlatforms, + "message": chatInputText, + } + ).then( (sendchatstuff) => { + console.debug('ChatRD] Sending Chat to Streamer.Bot', sendchatstuff); + }); + + // Sends Message to TikTok that are not commands + if (chatSendPlatforms.includes('tiktok')) { + if (!chatInputText.startsWith('/')) { + streamerBotClient.doAction( + { name : "[TikTok] Msgs" }, + { + "ttkmessage": chatInputText, + } + ).then( (sendchatstuff) => { + console.debug('[ChatRD] Sending TikTok Chat to Streamer.Bot', sendchatstuff); + }); + } + } + + chatInput.value = ''; +}); + +chatInputSend.addEventListener("click", function () { + chatInputForm.requestSubmit(); +}); + +document.addEventListener('click', function (e) { + if (e.target !== chatcommandslist) { + chatcommandslist.innerHTML = ''; + } +}); + + + + + + +async function speakerBotTTSRead(clone,type) { + + var TTSMessage = ""; + + const { + header, + user, + action, + value, + 'actual-message': message + } = Object.fromEntries( + [...clone.querySelectorAll('[class]')] + .map(el => [el.className, el]) + ); + + if (type == "chat") { + var cleanmessage = ""; + + if (message == null) { cleanmessage = "
"; } + else { cleanmessage = message.innerHTML; } + + var strippedmessage = await cleanStringOfHTMLButEmotes(cleanmessage); + + + const tts = { + user: user.textContent, + message: strippedmessage + } + + + TTSMessage = renderTemplate(speakerBotChatTemplate, tts); + } + + if (type == "event") { + + var cleanvalue = ""; + if (value == null) { cleanvalue = ""; } + else { cleanvalue = value.innerHTML; } + + var cleanmessage = ""; + if (message == null) { cleanmessage = "
"; } + else { cleanmessage = message.innerHTML; } + + var strippedmessage = await cleanStringOfHTMLButEmotes(cleanmessage); + var strippedaction = await cleanStringOfHTMLButEmotes(action.innerHTML); + var strippedvalue = await cleanStringOfHTMLButEmotes(cleanvalue); + + TTSMessage = user.textContent + strippedaction + strippedvalue + ". " + strippedmessage; + } + + speakerBotClient.speak(TTSMessage); + + /*streamerBotClient.doAction({ name : "[Speakerbot] TTS" }, + { + "message": TTSMessage, + "alias" : speakerBotVoiceAlias + } + ).then( (response) => { + console.debug('[ChatRD][Streamer.bot -> Speaker.bot] Sending TTS...', response); + });*/ + +} + + +function renderTemplate(template, data) { + return template.replace(/\{(\w+)\}/g, (match, key) => { + return key in data ? data[key] : match; + }); +} + + +async function cleanStringOfHTMLButEmotes(string) { + // Cria um elemento DOM temporário + const container = document.createElement('div'); + container.innerHTML = string; + + // Substitui ... por texto do alt + const emotes = container.querySelectorAll('img.emote[alt]'); + emotes.forEach(img => { + const altText = img.getAttribute('alt'); + const textNode = document.createTextNode(altText); + img.replaceWith(textNode); + }); + + // Remove todo o restante do HTML + return container.textContent || ""; +} + + +async function executeModCommand(event, command) { + event.preventDefault(); + chatInput.value = command; + chatInputForm.requestSubmit(); +} + +document.addEventListener("DOMContentLoaded", function () { +}); \ No newline at end of file diff --git a/js/modules/fourthwall/images/logo-fourthwall.svg b/js/modules/fourthwall/images/logo-fourthwall.svg new file mode 100644 index 0000000..b517914 --- /dev/null +++ b/js/modules/fourthwall/images/logo-fourthwall.svg @@ -0,0 +1,12 @@ + + + + + + + + \ No newline at end of file diff --git a/js/modules/fourthwall/module.css b/js/modules/fourthwall/module.css new file mode 100644 index 0000000..cab49fd --- /dev/null +++ b/js/modules/fourthwall/module.css @@ -0,0 +1,11 @@ +#chat .event.fourthwall .message { + background: rgba(0,68,255,0.75); +} + +#chat .event.fourthwall .header { + padding: 5px 0; + text-align: center; +} +#chat .event.fourthwall .header img { + height: 128px; +} diff --git a/js/modules/fourthwall/module.js b/js/modules/fourthwall/module.js new file mode 100644 index 0000000..fba8354 --- /dev/null +++ b/js/modules/fourthwall/module.js @@ -0,0 +1,291 @@ +/* --------------------------- */ +/* FOURTHWALL MODULE VARIABLES */ +/* --------------------------- */ + +const showFourthwall = getURLParam("showFourthwall", true); + +const showFourthwallDonations = getURLParam("showFourthwallDonations", true); +const showFourthwallSubscriptions = getURLParam("showFourthwallSubscriptions", true); + +const showFourthwallOrders = getURLParam("showFourthwallOrders", true); +const showFourthwallShowImage = getURLParam("showFourthwallShowImage", true); + +const showFourthwallGiftPurchase = getURLParam("showFourthwallGiftPurchase", true); +const showFourthwallShowGiftImage = getURLParam("showFourthwallShowGiftImage", true); + +const showFourthwallGiftDraw = getURLParam("showFourthwallGiftDraw", true); +const fourthWallGiftDrawCommand = getURLParam("fourthWallGiftDrawCommand", "!enter"); + + +// FOURTHWALL EVENTS HANDLERS + +const fourthwallMessageHandlers = { + 'Fourthwall.Donation': (response) => { + fourthwallDonationMessage(response.data); + }, + 'Fourthwall.SubscriptionPurchased': (response) => { + fourthwallSubMessage(response.data); + }, + 'Fourthwall.OrderPlaced': (response) => { + fourthwallOrderMessage(response.data); + }, + 'Fourthwall.GiftPurchase': (response) => { + fourthwallGiftMessage(response.data); + }, + 'Fourthwall.GiftDrawStarted': (response) => { + fourthwallGiftDrawStartMessage(response.data); + }, + 'Fourthwall.GiftDrawEnded': (response) => { + fourthwallGiftDrawEndMessage(response.data); + }, +}; + +if (showFourthwall) { + registerPlatformHandlersToStreamerBot(fourthwallMessageHandlers, '[Fourthwall]'); +} + + + +// FOURTHWALL EVENT FUNCTIONS + +async function fourthwallDonationMessage(data) { + + if (showFourthwallDonations == false) return; + + const template = eventTemplate; + const clone = template.content.cloneNode(true); + const messageId = createRandomString(40); + const userId = createRandomString(40); + + const { + header, + platform, + user, + action, + value, + 'actual-message': message + } = Object.fromEntries( + [...clone.querySelectorAll('[class]')] + .map(el => [el.className, el]) + ); + + const classes = ['fourthwall', 'donation']; + + header.remove(); + + + user.innerHTML = `${data.username}`; + action.innerHTML = ` donated `; + + var money = formatCurrency(data.amount,data.currency); + value.innerHTML = `${money}`; + + if (data.message) message.innerHTML = `${data.message}`; + + addEventItem('fourthwall', clone, classes, userId, messageId); +} + + + +async function fourthwallOrderMessage(data) { + + if (showFourthwallOrders == false) return; + + const template = eventTemplate; + const clone = template.content.cloneNode(true); + const messageId = createRandomString(40); + const userId = createRandomString(40); + + const username = data.username; + const total = data.total; + const currency = data.currency; + const item = data.variants[0].name; + const itemsQuantity = data.variants.length; + const text = stripStringFromHtml(data.statmessageus); + const imageUrl = data?.variants?.[0]?.image ?? ''; + + const { + header, + platform, + user, + action, + value, + 'actual-message': message + } = Object.fromEntries( + [...clone.querySelectorAll('[class]')] + .map(el => [el.className, el]) + ); + + const classes = ['fourthwall', 'order']; + + if (showFourthwallShowImage == true) { + if (imageUrl) { header.innerHTML = ``; } + else { header.remove(); } + } + else { header.remove(); } + + + var userName = ''; + if (username == undefined) { userName = 'Someone'; } + else { userName = username; } + + user.innerHTML = `${userName}`; + action.innerHTML = ` ordered `; + + var money = formatCurrency(total,currency); + var html = `${item}`; + + if (itemsQuantity > 1) { html += ` and ${itemsQuantity - 1} other ${(itemsQuantity - 1) == 1 ? 'item' : 'items'} (${total == 0 ? 'Free' : money})`; } + else { html += ` (${total == 0 ? 'Free' : money})`; } + + value.innerHTML = html; + + if (text) message.innerHTML = `${text}`; + + addEventItem('fourthwall', clone, classes, userId, messageId); +} + + + +async function fourthwallGiftMessage(data) { + + if (showFourthwallGiftPurchase == false) return; + + const template = eventTemplate; + const clone = template.content.cloneNode(true); + const messageId = createRandomString(40); + const userId = createRandomString(40); + + const username = data.username; + const total = data.total; + const currency = data.currency; + const item = data.offer.name; + const itemsQuantity = data.gifts.length; + const text = stripStringFromHtml(data.statmessageus); + const imageUrl = data?.offer?.imageUrl ?? ''; + + const { + header, + platform, + user, + action, + value, + 'actual-message': message + } = Object.fromEntries( + [...clone.querySelectorAll('[class]')] + .map(el => [el.className, el]) + ); + + const classes = ['fourthwall', 'gift']; + + if (showFourthwallShowGiftImage == true) { + if (imageUrl) { header.innerHTML = ``; } + else { header.remove(); } + } + else { header.remove(); } + + + user.innerHTML = `${userName}`; + action.innerHTML = ` gifted `; + + var money = formatCurrency(total,currency); + var html = `${itemsQuantity} ${item}`; + html += ` (${total == 0 ? 'Free' : money})`; + + value.innerHTML = html; + + if (text) message.innerHTML = `${text}`; + + addEventItem('fourthwall', clone, classes, userId, messageId); +} + + + +async function fourthwallGiftDrawStartMessage(data) { + + if (showFourthwallGiftDraw == false) return; + + const template = eventTemplate; + const clone = template.content.cloneNode(true); + const messageId = createRandomString(40); + const userId = createRandomString(40); + + + const { + header, + platform, + user, + action, + value, + 'actual-message': message + } = Object.fromEntries( + [...clone.querySelectorAll('[class]')] + .map(el => [el.className, el]) + ); + + const classes = ['fourthwall', 'giftdraw']; + + header.remove(); + + + user.innerHTML = ` Giveaway started!`; + action.innerHTML = ` Type ${fourthWallGiftDrawCommand} to have a chance to win `; + value.innerHTML = `${data.offer.name}. ` + message.innerHTML = `You have ${durationSeconds} seconds! Good Luck!`; + + addEventItem('fourthwall', clone, classes, userId, messageId); +} + + + +async function fourthwallGiftDrawEndMessage(data) { + + if (showFourthwallGiftDraw == false) return; + + const template = eventTemplate; + const clone = template.content.cloneNode(true); + const messageId = createRandomString(40); + const userId = createRandomString(40); + + const { + header, + platform, + user, + action, + value, + 'actual-message': message + } = Object.fromEntries( + [...clone.querySelectorAll('[class]')] + .map(el => [el.className, el]) + ); + + const classes = ['fourthwall', 'giftdrawend']; + + header.remove(); + + + user.innerHTML = `🎉 Giveaway Ended!`; + action.innerHTML = ` Congratulations to: `; + value.remove(); + + var winners = await getWinnersList(data.gifts); + + message.innerHTML = `${winners}`; + + addEventItem('fourthwall', clone, classes, userId, messageId); +} + +async function getWinnersList(gifts) { + const winners = gifts.map(gift => gift.winner).filter(Boolean); // Remove null/undefined + + const numWinners = winners.length; + + if (numWinners === 0) { return ""; } + if (numWinners === 1) { return winners[0]; } + if (numWinners === 2) { return `${winners[0]} and ${winners[1]}`; } + + // For 3 or more, use the Oxford comma style: A, B, and C + const allButLast = winners.slice(0, -1).join(", "); + const lastWinner = winners[winners.length - 1]; + return `${allButLast}, and ${lastWinner}`; +} diff --git a/js/modules/kick/images/badge-bot.svg b/js/modules/kick/images/badge-bot.svg new file mode 100644 index 0000000..3d323d2 --- /dev/null +++ b/js/modules/kick/images/badge-bot.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/js/modules/kick/images/badge-broadcaster.svg b/js/modules/kick/images/badge-broadcaster.svg new file mode 100644 index 0000000..83900f4 --- /dev/null +++ b/js/modules/kick/images/badge-broadcaster.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/js/modules/kick/images/badge-founder.svg b/js/modules/kick/images/badge-founder.svg new file mode 100644 index 0000000..6bdc644 --- /dev/null +++ b/js/modules/kick/images/badge-founder.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/js/modules/kick/images/badge-moderator.svg b/js/modules/kick/images/badge-moderator.svg new file mode 100644 index 0000000..791f54b --- /dev/null +++ b/js/modules/kick/images/badge-moderator.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/js/modules/kick/images/badge-og.svg b/js/modules/kick/images/badge-og.svg new file mode 100644 index 0000000..4de28cc --- /dev/null +++ b/js/modules/kick/images/badge-og.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/js/modules/kick/images/badge-sidekick.svg b/js/modules/kick/images/badge-sidekick.svg new file mode 100644 index 0000000..c3abcd1 --- /dev/null +++ b/js/modules/kick/images/badge-sidekick.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/js/modules/kick/images/badge-sub_gifter.svg b/js/modules/kick/images/badge-sub_gifter.svg new file mode 100644 index 0000000..f5cded1 --- /dev/null +++ b/js/modules/kick/images/badge-sub_gifter.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/js/modules/kick/images/badge-subscriber.svg b/js/modules/kick/images/badge-subscriber.svg new file mode 100644 index 0000000..128b91c --- /dev/null +++ b/js/modules/kick/images/badge-subscriber.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/js/modules/kick/images/badge-verified.svg b/js/modules/kick/images/badge-verified.svg new file mode 100644 index 0000000..80bae1b --- /dev/null +++ b/js/modules/kick/images/badge-verified.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/js/modules/kick/images/badge-vip.svg b/js/modules/kick/images/badge-vip.svg new file mode 100644 index 0000000..8503584 --- /dev/null +++ b/js/modules/kick/images/badge-vip.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/js/modules/kick/images/logo-kick.svg b/js/modules/kick/images/logo-kick.svg new file mode 100644 index 0000000..29c753d --- /dev/null +++ b/js/modules/kick/images/logo-kick.svg @@ -0,0 +1,12 @@ + + + + + + + + \ No newline at end of file diff --git a/js/modules/kick/module.css b/js/modules/kick/module.css new file mode 100644 index 0000000..8651566 --- /dev/null +++ b/js/modules/kick/module.css @@ -0,0 +1,24 @@ +#chat .event.kick .message { + background: rgba(72,212,21,0.75); +} + +#chat .item.kick .platform .hidden-platform { + background: rgba(72,212,21,1); +} + +#chat .item.kick .badges img { + margin: 0 -1px; +} + +#chat .item .info .avatar { + position: relative; +} + +#chat .item .info .avatar img { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); +} + +#statistics .platform#kick { background: #48d415; } \ No newline at end of file diff --git a/js/modules/kick/module.js b/js/modules/kick/module.js new file mode 100644 index 0000000..1404e41 --- /dev/null +++ b/js/modules/kick/module.js @@ -0,0 +1,726 @@ +/* ---------------------- */ +/* KICK MODULE VARIABLES */ +/* ---------------------- */ + +const showKick = getURLParam("showKick", true); + +const kickUserName = getURLParam("kickUserName", "vortisrd"); + +const showKickMessages = getURLParam("showKickMessages", true); +const showKickFollows = getURLParam("showKickFollows", true); +const showKickSubs = getURLParam("showKickSubs", true); +const showKickGiftedSubs = getURLParam("showKickGiftedSubs", true); +const showKickMassGiftedSubs = getURLParam("showKickMassGiftedSubs", true); +const showKickGiftedSubsUserTrain = getURLParam("showKickGiftedSubsUserTrain", true); +const showKickRewardRedemptions = getURLParam("showKickRewardRedemptions", true); +const showKickRaids = getURLParam("showKickRaids", true); +const showKickViewers = getURLParam("showKickViewers", true); + +const kickAvatars = new Map(); +const kick7TVEmojis = new Map(); +const kickSubBadges = []; + +const kickWebSocketURL = 'wss://ws-us2.pusher.com/app/32cbd69e4b950bf97679?protocol=7&client=js&version=8.4.0&flash=false'; + + + + + +// KICK EVENTS HANDLERS + +const kickMessageHandlers = { + + /*'Kick.ChatMessage': (response) => {\ + kickChatMessage(response.data); + },*/ + + 'Kick.Follow': (response) => { + kickFollowMessage(response.data); + }, + +}; + + + +document.addEventListener('DOMContentLoaded', () => { + if (showKick) { + + const kickStatistics = ` + + `; + + document.querySelector('#statistics').insertAdjacentHTML('beforeend', kickStatistics); + + if (showKickViewers == true) { document.querySelector('#statistics #kick').style.display = ''; } + + console.debug('[Kick][Debug] DOMContentLoaded fired'); + + registerPlatformHandlersToStreamerBot(kickMessageHandlers, '[Kick][SB1]'); + + kickConnection(); + } + +}); + + + + + + +// ----------------------- +// KICK CONNECT HANDLER + +async function kickConnection() { + if (!kickUserName) return; + + const kickMaxTries = 20; + const kickReconnectDelay = 10000; + let retryCount = 0; + + + + async function connect() { + try { + const kickUserInfo = await kickGetUserInfo(kickUserName); + const kickUserId = kickUserInfo.user_id; + + if (!kickUserInfo || !kickUserInfo.chatroom || !kickUserInfo.chatroom.id) { + throw new Error('Chatroom ID not found'); + } + + console.debug(`[Kick] User info for ${kickUserName}!`, kickUserInfo); + + const kickChatRoomId = kickUserInfo.chatroom.id; + + if (!kickChatRoomId) { + console.error(`[Kick] Could not find chatroom id for ${kickUserName}!`); + return; + } + + console.debug(`[Kick] Chatroom for ${kickUserName} Found! (ID: ${kickChatRoomId})`); + + kickSubBadges.push(...kickUserInfo.subscriber_badges); + + const kickWebSocket = new WebSocket(kickWebSocketURL); + + kickWebSocket.onopen = () => { + kickConnectionState = true; + retryCount = 0; + + console.debug(`[Kick] Connected to Kick!`); + notifySuccess({ + title: 'Connected to Kick', + text: `User set to ${kickUserName}.` + }); + + + + // Getting 7TV User Emotes and Global Emotes + (async () => { + const kick7TVEmotes = await getKick7TVEmotes(kickUserId); + if (kick7TVEmotes != null) { + kick7TVEmotes.forEach(emote => { + kick7TVEmojis.set(emote.name, emote.url); + }); + } + })(); + + + + + if (showKickViewers === true) { + setInterval(() => { + kickGetUserInfo(kickUserName).then(data => { + kickUpdateStatistics(data); + }); + }, 15000); + } + }; + + kickWebSocket.onmessage = (response) => { + const data = JSON.parse(response.data); + const kickData = JSON.parse(data.data); + const kickEvent = data.event.split('\\').pop(); + + console.debug(`[Kick] ${kickEvent}`, kickData); + + if (data.event === 'pusher:connection_established') { + + console.debug(`[Kick][Pusher] Connection established! (ID:${kickData.socket_id})`); + + const channels = [ + `chatroom_${kickChatRoomId}`, + `chatrooms.${kickChatRoomId}`, + `chatrooms.${kickChatRoomId}.v2`, + `predictions-channel-${kickChatRoomId}` + ]; + + channels.forEach(channel => { + kickWebSocket.send(JSON.stringify({ + event: 'pusher:subscribe', + data: { channel } + })); + }); + } + + if (data.event === "pusher:ping") { + kickWebSocket.send(JSON.stringify({ + event: "pusher:pong", + data: {} + })); + } + + switch (kickEvent) { + case 'ChatMessageEvent': kickChatMessage(kickData); break; + case 'SubscriptionEvent': kickSubMessage(kickData); break; + case 'GiftedSubscriptionsEvent': kickGiftMessage(kickData); break; + case 'RewardRedeemedEvent': kickRewardRedemption(kickData); break; + case 'StreamHostEvent': kickRaidMessage(kickData); break; + case 'MessageDeletedEvent': kickChatMessageDeleted(kickData); break; + case 'UserBannedEvent': kickUserBanned(kickData); break; + case 'ChatroomClearEvent': kickChatClearMessages(); break; + } + }; + + kickWebSocket.onclose = (event) => { + setTimeout(connect, kickReconnectDelay); + + /*console.warn(`[Kick] WebSocket closed (code: ${event.code})`); + + if (retryCount < kickMaxTries) { + retryCount++; + notifyError({ + title: 'Kick Disconnected', + text: `Retrying in ${kickReconnectDelay / 1000}s (${retryCount}/${kickMaxTries})` + }); + setTimeout(connect, kickReconnectDelay); + } else { + notifyError({ + title: 'Kick Reconnect Failed', + text: `Maximum retries (${kickMaxTries}) reached.` + }); + }*/ + }; + + kickWebSocket.onerror = (error) => { + console.error('[Kick] WebSocket error:', error); + kickWebSocket.close(); + }; + + } + catch (error) { + setTimeout(connect, kickReconnectDelay); + + /*console.error(`[Kick] Failed to connect: ${error.message}`); + + if (retryCount < kickMaxTries) { + retryCount++; + notifyError({ + title: 'Kick Connection Error', + text: `Retrying in ${kickReconnectDelay / 1000}s (${retryCount}/${kickMaxTries})` + }); + setTimeout(connect, kickReconnectDelay); + } else { + notifyError({ + title: 'Kick Reconnect Failed', + text: `Maximum retries (${kickMaxTries}) reached.` + }); + }*/ + } + } + + return await connect(); +} + + + + + +// --------------------------- +// KICK UTILITY FUNCTIONS + +async function kickChatMessage(data) { + + if (showKickMessages == false) return; + if (ignoreUserList.includes(data.sender.username.toLowerCase())) return; + if (data.content.startsWith("!") && excludeCommands == true) return; + + const template = chatTemplate; + const clone = template.content.cloneNode(true); + const messageId = data.id; + const userId = data.sender.id; + const userSlug = data.sender.slug; + + const { + 'first-message': firstMessage, + 'shared-chat': sharedChat, + + header, + timestamp, + platform, + badges, + avatar, + pronouns: pronoun, + user, + + reply, + 'actual-message': message + } = Object.fromEntries( + [...clone.querySelectorAll('[class]')] + .map(el => [el.className, el]) + ); + + const classes = ['kick', 'chat']; + + if (userSlug == kickUserName) classes.push('streamer'); + + const [avatarImage, messageHTML, badgesHTML] = await Promise.all([ + getKickAvatar(data.sender.slug), + getKickEmotes(data.content), + getKickBadges(data.sender.identity.badges), + ]); + + header.remove(); + firstMessage.remove(); + + user.style.color = data.sender.identity.color; + user.innerHTML = `${data.sender.username}`; + message.innerHTML = messageHTML; + + if (showAvatar) avatar.innerHTML = ``; else avatar.remove(); + if (showBadges) { + if (!badgesHTML) { badges.remove(); } + else { badges.innerHTML = badgesHTML; } + } + else { badges.remove(); } + + if (data.type == "reply") { + classes.push('reply'); + var replyHTML = await getKickEmotes(data.metadata.original_message.content); + reply.insertAdjacentHTML('beforeend', ` ${data.metadata.original_sender.username}: ${replyHTML}`); + } + else { reply.remove(); } + + sharedChat.remove(); + pronoun.remove(); + + addMessageItem('kick', clone, classes, userSlug, messageId); +} + + + +async function kickFollowMessage(data) { + + if (showKickFollows == false) return; + + const template = eventTemplate; + const clone = template.content.cloneNode(true); + const messageId = createRandomString(40); + const userId = data.user.login.toLowerCase(); + //const userId = data.userName.toLowerCase(); + + const { + header, + platform, + user, + action, + value, + 'actual-message': message + } = Object.fromEntries( + [...clone.querySelectorAll('[class]')] + .map(el => [el.className, el]) + ); + + const classes = ['kick', 'follow']; + + header.remove(); + + + user.innerHTML = `${data.user.name}`; + //user.innerHTML = `${data.userName}`; + + action.innerHTML = ` followed you`; + + value.remove() + + message.remove(); + + addEventItem('kick', clone, classes, userId, messageId); +} + + + +async function kickSubMessage(data) { + + if (showKickSubs == false) return; + + const template = eventTemplate; + const clone = template.content.cloneNode(true); + const messageId = createRandomString(40); + const userId = data.username.toLowerCase(); + + const { + header, + platform, + user, + action, + value, + 'actual-message': message + } = Object.fromEntries( + [...clone.querySelectorAll('[class]')] + .map(el => [el.className, el]) + ); + + const classes = ['kick', 'sub']; + + header.remove(); + + + user.innerHTML = `${data.username}`; + + action.innerHTML = ` subscribed for `; + + var months = data.months > 1 ? 'months' : 'month'; + + value.innerHTML = `${data.months} ${months}`; + + message.remove(); + + addEventItem('kick', clone, classes, userId, messageId); +} + + + +async function kickGiftMessage(data) { + + if (showKickGiftedSubs == false) return; + + const template = eventTemplate; + const clone = template.content.cloneNode(true); + const messageId = createRandomString(40); + const userId = data.gifter_username.toLowerCase(); + + const { + header, + platform, + user, + action, + value, + 'actual-message': message + } = Object.fromEntries( + [...clone.querySelectorAll('[class]')] + .map(el => [el.className, el]) + ); + + const classes = ['kick', 'gift']; + + header.remove(); + + + user.innerHTML = `${data.gifter_username}`; + + var giftedLength = data.gifted_usernames.length; + + if (giftedLength > 1 && showKickMassGiftedSubs == true) { + action.innerHTML = ` gifted ${giftedLength} subs to the Community`; + message.innerHTML = `They've gifted a total of ${data.gifter_total} subs`; + value.remove(); + + if (showKickGiftedSubsUserTrain == true) { + for (recipients of data.gifted_usernames) { + kickGiftSingleSub(data.gifter_username, recipients); + } + } + + addEventItem('kick', clone, classes, userId, messageId); + } + else { + kickGiftSingleSub(data.gifter_username, data.gifted_usernames[0]); + } + +} + + + +async function kickGiftSingleSub(gifter, recipient) { + const template = eventTemplate; + const clone = template.content.cloneNode(true); + const messageId = createRandomString(40); + const userId = gifter.toLowerCase(); + + const { + header, + platform, + user, + action, + value, + 'actual-message': message + } = Object.fromEntries( + [...clone.querySelectorAll('[class]')] + .map(el => [el.className, el]) + ); + + const classes = ['kick', 'gift']; + + header.remove(); + message.remove(); + + + user.innerHTML = `${gifter}`; + + action.innerHTML = ` gifted a subscription to `; + + value.innerHTML = `${recipient}`; + + addEventItem('kick', clone, classes, userId, messageId); +} + + + +async function kickRewardRedemption(data) { + + if (showKickRewardRedemptions == false) return; + + const template = eventTemplate; + const clone = template.content.cloneNode(true); + const messageId = createRandomString(40); + const userId = data.username.toLowerCase(); + + const { + header, + platform, + user, + action, + value, + 'actual-message': message + } = Object.fromEntries( + [...clone.querySelectorAll('[class]')] + .map(el => [el.className, el]) + ); + + const classes = ['kick', 'reward']; + + header.remove(); + + + user.innerHTML = `${data.username}`; + action.innerHTML = ` redeemed `; + value.innerHTML = `${data.reward_title}`; + + var userInput = data.user_input ? `- ${data.user_input}` : ''; + message.innerHTML = `${userInput}`; + + addEventItem('kick', clone, classes, userId, messageId); +} + + + + +async function kickRaidMessage(data) { + + if (showKickRaids == false) return; + + const template = eventTemplate; + const clone = template.content.cloneNode(true); + const messageId = createRandomString(40); + const userId = data.host_username.toLowerCase(); + + const { + header, + platform, + user, + action, + value, + 'actual-message': message + } = Object.fromEntries( + [...clone.querySelectorAll('[class]')] + .map(el => [el.className, el]) + ); + + const classes = ['kick', 'raid']; + + header.remove(); + message.remove(); + + + user.innerHTML = `${data.host_username}`; + + var viewers = data.number_viewers > 1 ? 'viewers' : 'viewer'; + action.innerHTML = ` hosted the channel with `; + value.innerHTML = `${data.number_viewers} ${viewers}`; + + addEventItem('kick', clone, classes, userId, messageId); +} + + + + + +async function kickChatMessageDeleted(data) { + document.getElementById(data.message.id)?.remove(); +} + + + +async function kickUserBanned(data) { + chatContainer.querySelectorAll(`[data-user="${data.user.slug}"]`).forEach(element => { + element.remove(); + }); +} + + + +async function kickChatClearMessages() { + chatContainer.querySelectorAll(`.item.kick`).forEach(element => { + element.remove(); + }); +} + + + +async function kickUpdateStatistics(data) { + if (showPlatformStatistics == false || showKickViewers == false) return; + if (data.livestream == null) { } + else { + const viewers = DOMPurify.sanitize(data.livestream.viewer_count); + document.querySelector('#statistics #kick .viewers span').textContent = formatNumber(viewers); + } +} + + + +async function kickGetUserInfo(user) { + const response = await fetch( `https://kick.com/api/v2/channels/${user}` ); + + if (response.status === 404) { + console.error("[Kick] User was not found!"); + return 404; + } + else { + const data = await response.json(); + return data; + } +} + +async function getKickAvatar(user) { + if (!showAvatar) return; + + const DEFAULT_AVATAR = 'https://kick.com/img/default-profile-pictures/default2.jpeg'; + + if (kickAvatars.has(user)) { + console.debug(`[Kick] Kick avatar found for ${user}!`); + return kickAvatars.get(user); + } + + console.debug(`[Kick] Kick avatar not found for ${user}! Trying to get it...`); + + try { + const response = await kickGetUserInfo(user); + const rawPic = response?.user?.profile_pic; + + const avatarUrl = (typeof rawPic === "string" && rawPic) + ? rawPic.replace(/fullsize\.webp$/, "medium.webp") + : DEFAULT_AVATAR; + + kickAvatars.set(user, avatarUrl); + return avatarUrl; + } + + catch (error) { + console.warn(`[Kick] Error getting Kick avatar for ${user}:`, error); + return DEFAULT_AVATAR; + } +} + + +async function getKickEmotes(text) { + var message = await parseKickEmojis(text); + message = await parseKick7TVEmotes(message); + return message; +} + +async function parseKickEmojis(content) { + const message = content; + const messagewithemotes = message.replace(/\[emote:(\d+):([^\]]+)\]/g, (_, id, name) => { + return `${name}`; + }); + + return messagewithemotes; +} + +async function parseKick7TVEmotes(text) { + const words = text.split(/\s+/); + + const parsedWords = words.map(word => { + if (kick7TVEmojis.has(word)) { + const url = kick7TVEmojis.get(word); + return `${word}`; + } + return word; + }); + + return parsedWords.join(' '); +} + + +async function getKick7TVEmotes(userId) { + const userSet = await fetch(`https://7tv.io/v3/users/kick/${userId}`); + + if (userSet.status === 404) { + console.debug("[Kick] 7TV Profile based on this Kick user was not found"); + return null; + } + + const userEmojis = await userSet.json(); + + const gettingAllKick7TVEmotes = userEmojis?.emote_set?.emotes?.map(emote => ({ + name: emote.name, + id: emote.id, + url: `https://cdn.7tv.app/emote/${emote.id}/1x.webp` + })) || []; + + const globalSet = await fetch(`https://7tv.io/v3/emote-sets/global`); + const globalEmojis = await globalSet.json(); + + const gettingAllGlobal7TVEmotes = globalEmojis?.emotes?.map(emote => ({ + name: emote.name, + id: emote.id, + url: `https://cdn.7tv.app/emote/${emote.id}/1x.webp` + })) || []; + + const SevenTVEmotesFusion = [...gettingAllKick7TVEmotes, ...gettingAllGlobal7TVEmotes]; + + if (SevenTVEmotesFusion != null) { + console.debug("[Kick] Getting all Kick's user 7TV Emojis + Globals", SevenTVEmotesFusion); + + SevenTVEmotesFusion.forEach(emote => { + kick7TVEmojis.set(emote.name, emote.url); + }); + } +} + +async function getKickBadges(badges) { + const badgesArray = []; + + badges.forEach(badge => { + if (badge.type === 'subscriber') { + + const targetMonths = badge.count; + + // Sort badges by months + const eligibleBadges = kickSubBadges + .filter(badge => badge.months <= targetMonths) + .sort((a, b) => b.months - a.months); // sorts from highest to lowest + + badgesArray.push(``); + } + else { + badgesArray.push(``); + } + }); + + return badgesArray.join(' '); +} \ No newline at end of file diff --git a/js/modules/kofi/images/logo-kofi.svg b/js/modules/kofi/images/logo-kofi.svg new file mode 100644 index 0000000..1fa0451 --- /dev/null +++ b/js/modules/kofi/images/logo-kofi.svg @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/js/modules/kofi/module.css b/js/modules/kofi/module.css new file mode 100644 index 0000000..0834343 --- /dev/null +++ b/js/modules/kofi/module.css @@ -0,0 +1,11 @@ +#chat .event.kofi .message { + background: rgba(114, 165, 242,0.75); +} + +#chat .event.kofi .header { + padding: 5px 0; + text-align: center; +} +#chat .event.kofi .header img { + height: 128px; +} diff --git a/js/modules/kofi/module.js b/js/modules/kofi/module.js new file mode 100644 index 0000000..f486781 --- /dev/null +++ b/js/modules/kofi/module.js @@ -0,0 +1,192 @@ +/* --------------------- */ +/* KOFI MODULE VARIABLES */ +/* --------------------- */ + +const showKofi = getURLParam("showKofi", true); + +const showKofiSubscriptions = getURLParam("showKofiSubscriptions", true); +const showKofiDonations = getURLParam("showKofiDonations", true); +const showKofiOrders = getURLParam("showKofiOrders", true); + + +// KOFI EVENTS HANDLERS + +const kofiMessageHandlers = { + 'Kofi.Donation': (response) => { + kofiDonationMessage(response.data); + }, + 'Kofi.Subscription': (response) => { + kofiSubMessage(response.data); + }, + 'Kofi.Resubscription': (response) => { + kofiReSubMessage(response.data); + }, + 'Kofi.ShopOrder': (response) => { + kofiOrderMessage(response.data); + }, +}; + +if (showKofi) { + registerPlatformHandlersToStreamerBot(kofiMessageHandlers, '[Ko-Fi]'); +} + + + +// KOFI EVENT FUNCTIONS + +async function kofiDonationMessage(data) { + + if (kofiDonationMessage == false) return; + + const template = eventTemplate; + const clone = template.content.cloneNode(true); + const messageId = createRandomString(40); + const userId = createRandomString(40); + + const { + header, + platform, + user, + action, + value, + 'actual-message': message + } = Object.fromEntries( + [...clone.querySelectorAll('[class]')] + .map(el => [el.className, el]) + ); + + const classes = ['kofi', 'donation']; + + header.remove(); + + + user.innerHTML = `${data.from}`; + action.innerHTML = ` donated `; + + var money = formatCurrency(data.amount,data.currency); + value.innerHTML = `${money}`; + + if (data.message) message.innerHTML = `${data.message}`; + + addEventItem('kofi', clone, classes, userId, messageId); +} + + + +async function kofiSubMessage(data) { + + if (showKofiSubscriptions == false) return; + + const template = eventTemplate; + const clone = template.content.cloneNode(true); + const messageId = createRandomString(40); + const userId = createRandomString(40); + + const { + header, + platform, + user, + action, + value, + 'actual-message': message + } = Object.fromEntries( + [...clone.querySelectorAll('[class]')] + .map(el => [el.className, el]) + ); + + const classes = ['kofi', 'sub']; + + header.remove(); + + + user.innerHTML = `${data.from}`; + action.innerHTML = ` just subscribed `; + + var money = formatCurrency(data.amount,data.currency); + value.innerHTML = `(${money})`; + + if (data.message) message.innerHTML = `${data.message}`; + + addEventItem('kofi', clone, classes, userId, messageId); +} + + + +async function kofiReSubMessage(data) { + + if (showKofiSubscriptions == false) return; + + const template = eventTemplate; + const clone = template.content.cloneNode(true); + const messageId = createRandomString(40); + const userId = createRandomString(40); + + const { + header, + platform, + user, + action, + value, + 'actual-message': message + } = Object.fromEntries( + [...clone.querySelectorAll('[class]')] + .map(el => [el.className, el]) + ); + + const classes = ['kofi', 'sub']; + + header.remove(); + + + user.innerHTML = `${data.from}`; + action.innerHTML = ` just resubscribed `; + + var money = formatCurrency(data.amount,data.currency); + value.innerHTML = `(${money}) ${data.tier ? '(Tier '+data.tier+')' : ''}`; + + if (data.message) message.innerHTML = `${data.message}`; + + addEventItem('kofi', clone, classes, userId, messageId); +} + + + +async function kofiOrderMessage(data) { + + if (showKofiOrders == false) return; + + const template = eventTemplate; + const clone = template.content.cloneNode(true); + const messageId = createRandomString(40); + const userId = createRandomString(40); + + const { + header, + platform, + user, + action, + value, + 'actual-message': message + } = Object.fromEntries( + [...clone.querySelectorAll('[class]')] + .map(el => [el.className, el]) + ); + + const classes = ['kofi', 'sub']; + + header.remove(); + + + user.innerHTML = `${data.from}`; + action.innerHTML = ` just ordered `; + + var money = ''; + if (data.amount == 0) money = 'Free'; + else money = formatCurrency(data.amount,data.currency); + + value.innerHTML = `${data.items.length} ${data.items.length > 1 ? 'item' : 'items'} (${money})`; + + if (data.message) message.innerHTML = `${data.message}`; + + addEventItem('kofi', clone, classes, userId, messageId); +} \ No newline at end of file diff --git a/js/modules/patreon/images/logo-patreon.svg b/js/modules/patreon/images/logo-patreon.svg new file mode 100644 index 0000000..eb0d6ae --- /dev/null +++ b/js/modules/patreon/images/logo-patreon.svg @@ -0,0 +1,12 @@ + + + + + + + + \ No newline at end of file diff --git a/js/modules/patreon/module.css b/js/modules/patreon/module.css new file mode 100644 index 0000000..98e1927 --- /dev/null +++ b/js/modules/patreon/module.css @@ -0,0 +1,3 @@ +#chat .event.patreon .message { + background: rgba(255,89,0,0.75); +} \ No newline at end of file diff --git a/js/modules/patreon/module.js b/js/modules/patreon/module.js new file mode 100644 index 0000000..953c086 --- /dev/null +++ b/js/modules/patreon/module.js @@ -0,0 +1,60 @@ +/* ------------------------- */ +/* PATREON MODULE VARIABLES */ +/* ------------------------- */ + +const showPatreon = getURLParam("showPatreon", true); + +const showPatreonMemberships = getURLParam("showPatreonMemberships", true); + +// PATREON EVENTS HANDLERS + +const patreonHandlers = { + 'Patreon.PledgeCreated': (response) => { + patreonMemberships(response.data); + }, +}; + +if (showPatreon) { + registerPlatformHandlersToStreamerBot(patreonHandlers, '[Patreon]'); +} + + + +// PATREON EVENTS FUNCTIONS + +async function patreonMemberships(data) { + + if (showPatreonMemberships == false) return; + + const template = eventTemplate; + const clone = template.content.cloneNode(true); + const messageId = createRandomString(40); + const userId = createRandomString(40); + + const { + header, + platform, + user, + action, + value, + 'actual-message': message + } = Object.fromEntries( + [...clone.querySelectorAll('[class]')] + .map(el => [el.className, el]) + ); + + const classes = ['patreon', 'membership']; + + header.remove(); + + + var money = (data.attributes.will_pay_amount_cents / 100).toFixed(2); + + user.innerHTML = `${data.attributes.full_name}`; + action.innerHTML = ` donated `; + value.innerHTML = `${money}`; + + message.remove(); + + addEventItem('patreon', clone, classes, userId, messageId); +} \ No newline at end of file diff --git a/js/modules/streamelements/images/logo-streamelements.svg b/js/modules/streamelements/images/logo-streamelements.svg new file mode 100644 index 0000000..4e35147 --- /dev/null +++ b/js/modules/streamelements/images/logo-streamelements.svg @@ -0,0 +1,82 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/js/modules/streamelements/module.css b/js/modules/streamelements/module.css new file mode 100644 index 0000000..382c520 --- /dev/null +++ b/js/modules/streamelements/module.css @@ -0,0 +1,3 @@ +#chat .event.streamelements .message { + background: rgba(39, 0, 255, 0.75); +} \ No newline at end of file diff --git a/js/modules/streamelements/module.js b/js/modules/streamelements/module.js new file mode 100644 index 0000000..7b0a98c --- /dev/null +++ b/js/modules/streamelements/module.js @@ -0,0 +1,57 @@ +/* ------------------------------- */ +/* STREAMELEMENTS MODULE VARIABLES */ +/* ------------------------------ */ + +const showStreamelements = getURLParam("showStreamelements", true); + +const showStreamElementsTips = getURLParam("showStreamElementsTips", true); + +const streamElementsHandlers = { + 'StreamElements.Tip': (response) => { + streamElementsEventMessage(response.data); + }, +}; + +if (showStreamelements) { + registerPlatformHandlersToStreamerBot(streamElementsHandlers, '[Streamelements]'); +} + + +async function streamElementsEventMessage(data) { + + if (showTwitchRewardRedemptions == false) return; + + const template = eventTemplate; + const clone = template.content.cloneNode(true); + const messageId = createRandomString(40); + const userId = createRandomString(40); + + const { + header, + platform, + user, + action, + value, + 'actual-message': message + } = Object.fromEntries( + [...clone.querySelectorAll('[class]')] + .map(el => [el.className, el]) + ); + + const classes = ['streamelements', 'donation']; + + header.remove(); + + + var money = formatCurrency(data.amount,data.currency); + + user.innerHTML = `${data.username}`; + action.innerHTML = ` donated `; + value.innerHTML = `${money}`; + + if (data.message) { message.innerHTML = `${data.message}`; } + else { message.remove(); } + + + addEventItem('streamelements', clone, classes, userId, messageId); +} \ No newline at end of file diff --git a/js/modules/streamlabs/images/logo-streamlabs.svg b/js/modules/streamlabs/images/logo-streamlabs.svg new file mode 100644 index 0000000..fbe8e73 --- /dev/null +++ b/js/modules/streamlabs/images/logo-streamlabs.svg @@ -0,0 +1,14 @@ + + + + + + + + + \ No newline at end of file diff --git a/js/modules/streamlabs/module.css b/js/modules/streamlabs/module.css new file mode 100644 index 0000000..e20b7a3 --- /dev/null +++ b/js/modules/streamlabs/module.css @@ -0,0 +1,3 @@ +#chat .event.streamlabs .message { + background: rgba(128,245,210,0.75); +} \ No newline at end of file diff --git a/js/modules/streamlabs/module.js b/js/modules/streamlabs/module.js new file mode 100644 index 0000000..7cac057 --- /dev/null +++ b/js/modules/streamlabs/module.js @@ -0,0 +1,59 @@ +/* ---------------------------- */ +/* STREAMLABS MODULE VARIABLES */ +/* ---------------------------- */ + +const showStreamlabs = getURLParam("showStreamlabs", true); + +const showStreamlabsDonations = getURLParam("showStreamlabsDonations", true); + +const streamlabsHandlers = { + 'Streamlabs.Donation' : (response) => { + streamLabsEventMessage(response.data); + }, +}; + + +if (showStreamlabs) { + registerPlatformHandlersToStreamerBot(streamlabsHandlers, '[Streamlabs]'); +} + + + +async function streamLabsEventMessage(data) { + + if (showStreamlabsDonations == false) return; + + const template = eventTemplate; + const clone = template.content.cloneNode(true); + const messageId = createRandomString(40); + const userId = createRandomString(40); + + const { + header, + platform, + user, + action, + value, + 'actual-message': message + } = Object.fromEntries( + [...clone.querySelectorAll('[class]')] + .map(el => [el.className, el]) + ); + + const classes = ['streamlabs', 'donation']; + + header.remove(); + + + var money = formatCurrency(data.amount,data.currency); + + user.innerHTML = `${data.from}`; + action.innerHTML = ` donated `; + value.innerHTML = `${money}`; + + if (data.message) { message.innerHTML = `${data.message}`; } + else { message.remove(); } + + + addEventItem('streamlabs', clone, classes, userId, messageId); +} \ No newline at end of file diff --git a/js/modules/tiktok/images/logo-tiktok.svg b/js/modules/tiktok/images/logo-tiktok.svg new file mode 100644 index 0000000..b674a5f --- /dev/null +++ b/js/modules/tiktok/images/logo-tiktok.svg @@ -0,0 +1,12 @@ + + + + + + + + \ No newline at end of file diff --git a/js/modules/tiktok/module.css b/js/modules/tiktok/module.css new file mode 100644 index 0000000..6af2fbd --- /dev/null +++ b/js/modules/tiktok/module.css @@ -0,0 +1,30 @@ +#chat .event.tiktok .message { + background: rgba(255,0,80,0.75); +} + +#chat .item.tiktok .platform .hidden-platform { + background: rgba(255,0,80,1); +} + +#chat .item.tiktok span.badge { + display: inline-flex; + justify-content: center; + align-items: center; + vertical-align: top; + color: #FFF; + background: #121212; + width: 22px; + height: 22px; + font-size: 12px; + border-radius: 3px; + margin: 0 1px; +} + + +#chat .event.tiktok.gift .value img { vertical-align: sub; } + +#chat.horizontal .item.tiktok .info .badges { + transform: translateY(2px); +} + +#statistics .platform#tiktok { background: #ff0050; } \ No newline at end of file diff --git a/js/modules/tiktok/module.js b/js/modules/tiktok/module.js new file mode 100644 index 0000000..4184c7c --- /dev/null +++ b/js/modules/tiktok/module.js @@ -0,0 +1,435 @@ +/* ----------------------- */ +/* TIKTOK MODULE VARIABLES */ +/* ----------------------- */ + +const showTiktok = getURLParam("showTiktok", true); + +const showTikTokMessages = getURLParam("showTikTokMessages", true); +const showTikTokFollows = getURLParam("showTikTokFollows", true); +const showTikTokLikes = getURLParam("showTikTokLikes", true); +const showTikTokGifts = getURLParam("showTikTokGifts", true); +const showTikTokSubs = getURLParam("showTikTokSubs", true); +const showTikTokStatistics = getURLParam("showTikTokStatistics", true); + +userColors.set('tiktok', new Map()); + +document.addEventListener('DOMContentLoaded', () => { + if (showTiktok) { + const tiktokStatistics = ` + + `; + + document.querySelector('#statistics').insertAdjacentHTML('beforeend', tiktokStatistics); + + if (showTikTokStatistics == true) { document.querySelector('#statistics #tiktok').style.display = ''; } + + console.debug('[TikTok][Debug] DOMContentLoaded fired'); + + tiktokConnection(); + } +}); + + + + + + +// ----------------------- +// TIKTOK CONNECT HANDLER + +async function tiktokConnection() { + const tikfinityWebSocketURL = 'ws://localhost:21213/'; // Replace with real URL + const reconnectDelay = 10000; // 10 seconds + const maxTries = 20; + let retryCount = 0; + + function connect() { + const tikfinityWebSocket = new WebSocket(tikfinityWebSocketURL); + + tikfinityWebSocket.onopen = () => { + console.debug(`[TikFinity] Connected to TikFinity successfully!`); + retryCount = 0; // Reset retry count on success + + notifySuccess({ + title: 'Connected to TikFinity', + text: `` + }); + }; + + tikfinityWebSocket.onmessage = (response) => { + + const data = JSON.parse(response.data); + const tiktokData = data.data; + + console.debug(`[TikTok] ${data.event}`, data.data); + + switch (data.event) { + case 'roomUser' : tiktokUpdateStatistics(tiktokData, 'viewers'); break; + case 'like': tiktokLikesMessage(tiktokData); tiktokUpdateStatistics(tiktokData, 'likes'); break; + case 'chat': tiktokChatMessage(tiktokData); break; + case 'follow': tiktokFollowMessage(tiktokData); break; + case 'gift': tiktokGiftMessage(tiktokData); break; + case 'subscribe': tiktokSubMessage(tiktokData); break; + } + }; + + tikfinityWebSocket.onclose = (event) => { + + setTimeout(() => { + connect(); + }, reconnectDelay); + + + /*console.error(`[TikFinity] Disconnected (code: ${event.code})`); + + if (retryCount < maxTries) { + retryCount++; + console.warn(`[TikFinity] Attempt ${retryCount}/${maxTries} - Reconnecting in ${reconnectDelay / 1000}s...`); + + notifyError({ + title: 'TikFinity Disconnected', + text: `Attempt ${retryCount}/${maxTries} - Reconnecting in ${reconnectDelay / 1000}...` + }); + + setTimeout(() => { + connect(); + }, reconnectDelay); + } + else { + notifyError({ + title: 'TikFinity Reconnect Failed', + text: `Maximum retries (${maxTries}) reached. Reload ChatRD to try again.
(Check DevTools Debug for more info).` + }); + console.error('[TikFinity] Max reconnect attempts reached. Giving up.'); + }*/ + }; + + tikfinityWebSocket.onerror = (error) => { + console.error(`[TikFinity] Connection error:`, error); + + // Force close to trigger onclose and centralize retry logic + if (tikfinityWebSocket.readyState !== WebSocket.CLOSED) { + tikfinityWebSocket.close(); + } + }; + + return tikfinityWebSocket; + } + + return connect(); // Returns the initial WebSocket instance +} + + + + + + + + + + + + + +// --------------------------- +// TIKTOK UTILITY FUNCTIONS + +async function tiktokChatMessage(data) { + + if (showTikTokMessages == false) return; + if (ignoreUserList.includes(data.nickname.toLowerCase())) return; + if (data.comment.startsWith("!") && excludeCommands == true) return; + + const template = chatTemplate; + const clone = template.content.cloneNode(true); + const messageId = data.msgId; + const userId = data.userId; + + const { + 'first-message': firstMessage, + 'shared-chat': sharedChat, + + header, + timestamp, + platform, + badges, + avatar, + pronouns: pronoun, + user, + + reply, + 'actual-message': message + } = Object.fromEntries( + [...clone.querySelectorAll('[class]')] + .map(el => [el.className, el]) + ); + + const classes = ['tiktok', 'chat']; + + if (data.isModerator) classes.push('mod'); + if (data.isSubscriber) classes.push('sub'); + + const [avatarImage, messageHTML, badgesHTML] = await Promise.all([ + getTikTokAvatar(data), + getTikTokEmotes(data), + getTikTokBadges(data), + ]); + + header.remove(); + firstMessage.remove(); + + sharedChat.remove(); + reply.remove(); + pronoun.remove(); + + if (showAvatar) avatar.innerHTML = ``; else avatar.remove(); + + if (showBadges) { + if (!badgesHTML) { badges.remove(); } + else { badges.innerHTML = badgesHTML; } + } + else { badges.remove(); } + + var color = await createRandomColor('tiktok', data.uniqueId); + + user.style.color = color; + user.innerHTML = `${data.nickname}`; + message.innerHTML = messageHTML; + + addMessageItem('tiktok', clone, classes, userId, messageId); +} + + + +async function tiktokFollowMessage(data) { + + if (showTikTokFollows == false) return; + + const template = eventTemplate; + const clone = template.content.cloneNode(true); + const messageId = data.msgId; + const userId = data.userId; + + const { + header, + platform, + user, + action, + value, + 'actual-message': message + } = Object.fromEntries( + [...clone.querySelectorAll('[class]')] + .map(el => [el.className, el]) + ); + + const classes = ['tiktok', 'follow']; + + header.remove(); + message.remove(); + value.remove(); + + + user.innerHTML = `${data.nickname}`; + + action.innerHTML = ` followed you`; + + addEventItem('tiktok', clone, classes, userId, messageId); +} + + + +async function tiktokLikesMessage(data) { + + if (showTikTokLikes == false) return; + + const template = eventTemplate; + const clone = template.content.cloneNode(true); + const messageId = data.msgId; + const userId = data.userId; + + const { + header, + platform, + user, + action, + value, + 'actual-message': message + } = Object.fromEntries( + [...clone.querySelectorAll('[class]')] + .map(el => [el.className, el]) + ); + + const classes = ['tiktok', 'likes']; + + + var likeCountTotal = parseInt(data.likeCount); + + // Search for Previous Likes from the Same User + const previousLikeContainer = chatContainer.querySelector(`div.event.tiktok.likes[data-user="${data.userId}"]`); + + // If found, fetches the previous likes, deletes the element + // and then creates a new count with a sum of the like count + if (previousLikeContainer) { + const likeCountElem = previousLikeContainer.querySelector('.value strong'); + if (likeCountElem) { + var likeCountPrev = parseInt(likeCountElem.textContent); + likeCountTotal = Math.floor(likeCountPrev + likeCountTotal); + removeItem(previousLikeContainer); + } + } + + header.remove(); + + + user.innerHTML = `${data.nickname}`; + action.innerHTML = ` sent you `; + + var likes = likeCountTotal > 1 ? 'likes' : 'like'; + value.innerHTML = `${likeCountTotal} ${likes} ❤️`; + + message.remove(); + + addEventItem('tiktok', clone, classes, userId, messageId); +} + + + +async function tiktokSubMessage(data) { + + if (showTikTokSubs == false) return; + + const template = eventTemplate; + const clone = template.content.cloneNode(true); + const messageId = data.msgId; + const userId = data.userId; + + const { + header, + platform, + user, + action, + value, + 'actual-message': message + } = Object.fromEntries( + [...clone.querySelectorAll('[class]')] + .map(el => [el.className, el]) + ); + + const classes = ['tiktok', 'sub']; + + header.remove(); + + + user.innerHTML = `${data.nickname}`; + action.innerHTML = ` subscribed for `; + + var months = data.subMonth > 1 ? 'months' : 'month'; + value.innerHTML = `${data.subMonth} ${months}`; + + message.remove(); + + addEventItem('tiktok', clone, classes, userId, messageId); +} + + + +async function tiktokGiftMessage(data) { + + if (showTikTokGifts == false) return; + if (data.giftType === 1 && !data.repeatEnd) return; + + const template = eventTemplate; + const clone = template.content.cloneNode(true); + const messageId = data.msgId; + const userId = data.userId; + + const { + header, + platform, + user, + action, + value, + 'actual-message': message + } = Object.fromEntries( + [...clone.querySelectorAll('[class]')] + .map(el => [el.className, el]) + ); + + const classes = ['tiktok', 'gift']; + + header.remove(); + + + user.innerHTML = `${data.nickname}`; + action.innerHTML = ` has sent you `; + value.innerHTML = `x${data.repeatCount} ${data.giftName} `; + + message.remove(); + + addEventItem('tiktok', clone, classes, userId, messageId); +} + + + +async function getTikTokEmotes(data) { + const { + comment: message, + emotes, + } = data; + + var fullmessage = message; + + if (emotes.length > 0) { + emotes.forEach(emote => { + var emotetoadd = ` `; + var position = emote.placeInComment; + fullmessage = [fullmessage.slice(0, position), emotetoadd, fullmessage.slice(position)].join(''); + }); + } + + return fullmessage; +} + + +async function getTikTokAvatar(data) { + const { + profilePictureUrl + } = data; + + return profilePictureUrl; +} + +async function getTikTokBadges(data) { + const { + isSubscriber, + isModerator, + } = data; + + let badgesHTML = [ + isSubscriber && '', + isModerator && '', + ].filter(Boolean).join(''); + + return badgesHTML; +} + + +async function tiktokUpdateStatistics(data, type) { + + if (showPlatformStatistics == false || showTikTokStatistics == false) return; + + if (type == 'viewers') { + const viewers = DOMPurify.sanitize(data.viewerCount); + document.querySelector('#statistics #tiktok .viewers span').textContent = formatNumber(viewers); + } + + if (type == 'likes') { + const likes = DOMPurify.sanitize(data.totalLikeCount); + document.querySelector('#statistics #tiktok .likes span').textContent = formatNumber(likes); + } + +} \ No newline at end of file diff --git a/js/modules/tipeeestream/images/logo-tipeeestream.svg b/js/modules/tipeeestream/images/logo-tipeeestream.svg new file mode 100644 index 0000000..b321836 --- /dev/null +++ b/js/modules/tipeeestream/images/logo-tipeeestream.svg @@ -0,0 +1,13 @@ + + + + + + + + + \ No newline at end of file diff --git a/js/modules/tipeeestream/module.css b/js/modules/tipeeestream/module.css new file mode 100644 index 0000000..4a34b23 --- /dev/null +++ b/js/modules/tipeeestream/module.css @@ -0,0 +1,3 @@ +#chat .event.tipeeestream .message { + background: rgba(224,47,68,0.75); +} \ No newline at end of file diff --git a/js/modules/tipeeestream/module.js b/js/modules/tipeeestream/module.js new file mode 100644 index 0000000..ca710b6 --- /dev/null +++ b/js/modules/tipeeestream/module.js @@ -0,0 +1,61 @@ +/* ----------------------------- */ +/* TIPEEESTREAM MODULE VARIABLES */ +/* ----------------------------- */ + +const showTipeee = getURLParam("showTipeee", true); + +const showTipeeeDonations = getURLParam("showTipeeeDonations", true); + +// TIPEEESTREAM EVENTS HANDLERS + +const tipeeeHandlers = { + 'TipeeeStream.Donation': (response) => { + tipeeeStreamDonation(response.data); + }, +}; + +if (showTipeee) { + registerPlatformHandlersToStreamerBot(tipeeeHandlers, '[Tipeeestream]'); +} + + + + +// TIPEEESTREAM EVENTS HANDLERS + +async function tipeeeStreamDonation(data) { + + if (tipeeeStreamDonation == false) return; + + const template = eventTemplate; + const clone = template.content.cloneNode(true); + const messageId = createRandomString(40); + const userId = createRandomString(40); + + const { + header, + platform, + user, + action, + value, + 'actual-message': message + } = Object.fromEntries( + [...clone.querySelectorAll('[class]')] + .map(el => [el.className, el]) + ); + + const classes = ['tipeeestream', 'donation']; + + header.remove(); + + + var money = formatCurrency(data.amount,data.currency); + + user.innerHTML = `${data.username}`; + action.innerHTML = ` donated `; + value.innerHTML = `${money}`; + + if (data.message) message.innerHTML = `: ${data.message}`; + + addEventItem('tipeeestream', clone, classes, userId, messageId); +} \ No newline at end of file diff --git a/js/modules/twitch/images/logo-twitch.svg b/js/modules/twitch/images/logo-twitch.svg new file mode 100644 index 0000000..f256c89 --- /dev/null +++ b/js/modules/twitch/images/logo-twitch.svg @@ -0,0 +1,17 @@ + + + + + + + + + \ No newline at end of file diff --git a/js/modules/twitch/module.css b/js/modules/twitch/module.css new file mode 100644 index 0000000..44e9112 --- /dev/null +++ b/js/modules/twitch/module.css @@ -0,0 +1,34 @@ +#chat .item.announcement.twitch .message { + background: rgba(145,70,255,0.75); +} + +#chat .item.twitch .platform .hidden-platform { + background: rgba(145,70,255,1); +} + +#chat .item.announcement.twitch.green .message { + background: linear-gradient(to bottom, rgba(0,219,132,0.75),rgba(87,190,230,0.75)); +} + +#chat .item.announcement.twitch.purple .message { + background: linear-gradient(to bottom, rgba(145,70,255,0.75),rgba(255,117,230,0.75)); +} + +#chat .item.announcement.twitch.orange .message { + background: linear-gradient(to bottom, rgba(255, 179, 26,0.75),rgba(224,224,0,0.75)); +} + +#chat .item.announcement.twitch.blue .message { + background: linear-gradient(to bottom, rgba(0,214,214,0.75),rgba(145,70,255,0.75)); +} + +#chat .event.twitch .message { + background: rgba(145,70,255,0.75); +} + +#chat .item img.gigantified { + display: block; + height: 96px; +} + +#statistics .platform#twitch { background: #a970ff; } \ No newline at end of file diff --git a/js/modules/twitch/module.js b/js/modules/twitch/module.js new file mode 100644 index 0000000..376adde --- /dev/null +++ b/js/modules/twitch/module.js @@ -0,0 +1,765 @@ +/* ----------------------- */ +/* TWITCH MODULE VARIABLES */ +/* ----------------------- */ + +const showTwitch = getURLParam("showTwitch", true); + +const showTwitchMessages = getURLParam("showTwitchMessages", true); +const showTwitchFollows = getURLParam("showTwitchFollows", true); +const showTwitchBits = getURLParam("showTwitchBits", true); +const showTwitchAnnouncements = getURLParam("showTwitchAnnouncements", true); +const showTwitchSubs = getURLParam("showTwitchSubs", true); +const showTwitchGiftedSubs = getURLParam("showTwitchGiftedSubs", true); +const showTwitchGiftedSubsUserTrain = getURLParam("showTwitchGiftedSubsUserTrain", true); +const showTwitchMassGiftedSubs = getURLParam("showTwitchMassGiftedSubs", true); +const showTwitchRewardRedemptions = getURLParam("showTwitchRewardRedemptions", true); +const showTwitchRaids = getURLParam("showTwitchRaids", true); +const showTwitchSharedChat = getURLParam("showTwitchSharedChat", true); +const showTwitchPronouns = getURLParam("showTwitchPronouns", false); +const showTwitchViewers = getURLParam("showTwitchViewers", true); + +const twitchAvatars = new Map(); +const twitchPronouns = new Map(); + +// TWITCH EVENTS HANDLERS + +const twitchMessageHandlers = { + 'Twitch.ChatMessage': (response) => { + twitchChatMessage(response.data); + }, + 'Twitch.Follow': (response) => { + twitchFollowMessage(response.data); + }, + 'Twitch.Announcement': (response) => { + twitchAnnouncementMessage(response.data); + }, + 'Twitch.Cheer': (response) => { + twitchBitsMessage(response.data); + }, + 'Twitch.AutomaticRewardRedemption': (response) => { + twitchChatMessageGiantEmote(response.data); + }, + 'Twitch.RewardRedemption': (response) => { + twitchRewardRedemption(response.data); + }, + 'Twitch.Sub': (response) => { + twitchSubMessage(response.data); + }, + 'Twitch.ReSub': (response) => { + twitchReSubMessage(response.data); + }, + 'Twitch.GiftSub': (response) => { + twitchGiftMessage(response.data); + }, + 'Twitch.GiftBomb': (response) => { + twitchGiftBombMessage(response.data); + }, + 'Twitch.Raid': (response) => { + twitchRaidMessage(response.data); + }, + 'Twitch.ChatMessageDeleted': (response) => { + twitchChatMessageDeleted(response.data); + }, + 'Twitch.UserBanned': (response) => { + twitchUserBanned(response.data); + }, + 'Twitch.UserTimedOut': (response) => { + twitchUserBanned(response.data); + }, + 'Twitch.ViewerCountUpdate': (response) => { + twitchUpdateStatistics(response.data); + }, + 'Twitch.ChatCleared': (response) => { + twitchChatClearMessages(); + } +}; + + + +if (showTwitch) { + + const twitchStatistics = ` + + `; + + document.querySelector('#statistics').insertAdjacentHTML('beforeend', twitchStatistics); + + if (showTwitchViewers == true) { document.querySelector('#twitch').style.display = ''; } + + registerPlatformHandlersToStreamerBot(twitchMessageHandlers, '[Twitch]'); + +} + + + +// --------------------------- +// TWITCH EVENT FUNCTIONS + +async function twitchChatMessage(data) { + + if (showTwitchMessages == false) return; + if (ignoreUserList.includes(data.message.username.toLowerCase())) return; + if (data.message.message.startsWith("!") && excludeCommands == true) return; + + const template = chatTemplate; + const clone = template.content.cloneNode(true); + const messageId = data.messageId; + const userId = data.message.username; + + const { + 'first-message': firstMessage, + 'shared-chat': sharedChat, + + header, + timestamp, + platform, + badges, + avatar, + pronouns: pronoun, + user, + + reply, + 'actual-message': message + } = Object.fromEntries( + [...clone.querySelectorAll('[class]')] + .map(el => [el.className, el]) + ); + + const classes = ['twitch', 'chat']; + + const [avatarImage, fullmessage, badgeList] = await Promise.all([ + getTwitchAvatar(data.message.username), + getTwitchEmotes(data), + getTwitchBadges(data) + ]); + + header.remove(); + + user.style.color = data.message.color; + user.innerHTML = `${data.message.displayName}`; + message.innerHTML = fullmessage; + + if (data.message.isMe) { + message.style.color = data.message.color; + } + + if (showAvatar) avatar.innerHTML = ``; else avatar.remove(); + if (showBadges) badges.innerHTML = badgeList; else badges.remove(); + + + if (data.user.role == 4) { classes.push('streamer'); } + + + if (data.message.firstMessage) { + classes.push('first-chatter'); + firstMessage.innerHTML('beforeend', ` First-time Chatter`); + } + else { firstMessage.remove(); } + + + if (data.message.isReply) { + classes.push('reply'); + reply.insertAdjacentHTML('beforeend', ` ${data.message.reply.userName}: ${data.message.reply.msgBody}`); + } + else { reply.remove(); } + + + if (data.message.isSharedChat) { + if (showTwitchSharedChat == true) { + classes.push('shared-chat'); + + if (!data.sharedChat.primarySource) { + sharedChat.querySelector('span.origin').insertAdjacentHTML('beforeend', ` ${data.sharedChat.sourceRoom.name}`); + } + } + else if (!data.sharedChat.primarySource && showTwitchSharedChat == false) { + return; + } + } + else { sharedChat.remove(); } + + + + if (showTwitchPronouns === true) { + const userPronouns = await getTwitchUserPronouns(data.message.username); + if (userPronouns) { + pronoun.innerHTML = userPronouns; + } + } + else { pronoun.remove(); } + + addMessageItem('twitch', clone, classes, userId, messageId); +} + + + +async function twitchChatMessageGiantEmote(data) { + + if (showTwitchMessages == false) return; + + const userMessages = chatContainer.querySelectorAll(`.chat.twitch[data-user="${data.user_login}"]`); + + if (userMessages.length === 0) return; + + const firstMessage = userMessages[0]; + const emoteImages = firstMessage.querySelectorAll(`img[data-emote-id="${data.gigantified_emote.id}"]`); + + if (emoteImages.length === 0) return; + + emoteImages.forEach(img => { + img.classList.add("gigantified"); + if (img.src.endsWith("2.0")) { + img.src = img.src.replace("2.0", "3.0"); + } + }); +} + + + + +async function twitchFollowMessage(data) { + + if (showTwitchFollows == false) return; + + const template = eventTemplate; + const clone = template.content.cloneNode(true); + const messageId = createRandomString(40); + const userId = data.user_name.toLowerCase(); + + const { + header, + platform, + user, + action, + value, + 'actual-message': message + } = Object.fromEntries( + [...clone.querySelectorAll('[class]')] + .map(el => [el.className, el]) + ); + + const classes = ['twitch', 'follow']; + + header.remove(); + message.remove(); + value.remove(); + + + user.innerHTML = `${data.user_name}`; + + action.innerHTML = ` followed you`; + + addEventItem('twitch', clone, classes, userId, messageId); +} + + + +async function twitchAnnouncementMessage(data) { + + if (showTwitchAnnouncements == false) return; + + const template = chatTemplate; + const clone = template.content.cloneNode(true); + const messageId = data.messageId; + const userId = data.user.name.toLowerCase(); + + const { + 'first-message': firstMessage, + 'shared-chat': sharedChat, + + header, + timestamp, + platform, + badges, + avatar, + pronouns: pronoun, + user, + + reply, + 'actual-message': message + } = Object.fromEntries( + [...clone.querySelectorAll('[class]')] + .map(el => [el.className, el]) + ); + + const classes = ['twitch', 'announcement']; + + classes.push(data.announcementColor.toLowerCase()); + + firstMessage.remove(); + sharedChat.remove(); + timestamp.remove(); + //platform.remove(); + avatar.remove(); + pronoun.remove(); + reply.remove(); + + const [fullmessage, badgeList] = await Promise.all([ + getTwitchEmotesOnParts(data), + getTwitchAnnouncementBadges(data) + ]); + + header.innerHTML = ` Announcement`; + + user.style.color = data.user.color; + user.innerHTML = `${data.user.name}`; + message.innerHTML = fullmessage; + + if (showBadges) badges.innerHTML = badgeList; else badges.remove(); + + addMessageItem('twitch', clone, classes, userId, messageId); +} + + + +async function twitchRewardRedemption(data) { + + if (showTwitchRewardRedemptions == false) return; + + const template = eventTemplate; + const clone = template.content.cloneNode(true); + const messageId = createRandomString(40); + const userId = data.user_name.toLowerCase(); + + const { + header, + platform, + user, + action, + value, + 'actual-message': message + } = Object.fromEntries( + [...clone.querySelectorAll('[class]')] + .map(el => [el.className, el]) + ); + + const classes = ['twitch', 'reward']; + + header.remove(); + + + user.innerHTML = `${data.user_name}`; + action.innerHTML = ` redeemed `; + value.innerHTML = `${data.reward.title} (${data.reward.cost})`; + + var userInput = data.user_input ? `- ${data.user_input}` : ''; + message.innerHTML = `${userInput}`; + + addEventItem('twitch', clone, classes, userId, messageId); +} + + + +async function twitchBitsMessage(data) { + + if (showTwitchBits == false) return; + + const template = eventTemplate; + const clone = template.content.cloneNode(true); + const messageId = data.messageId; + const userId = data.user.name.toLowerCase(); + + const { + header, + platform, + user, + action, + value, + 'actual-message': message + } = Object.fromEntries( + [...clone.querySelectorAll('[class]')] + .map(el => [el.className, el]) + ); + + const classes = ['twitch', 'bits']; + + header.remove(); + + + user.innerHTML = `${data.user.name}`; + action.innerHTML = ` cheered with `; + + var bits = data.message.bits > 1 ? 'bits' : 'bit'; + value.innerHTML = `${data.message.bits} ${bits}`; + + var fullmessage = data.message.message.replace(/\bCheer\d+\b/g, '').replace(/\s+/g, ' ').trim(); + message.innerHTML = fullmessage; + + addEventItem('twitch', clone, classes, userId, messageId); +} + + + +async function twitchSubMessage(data) { + + if (showTwitchSubs == false) return; + + const template = eventTemplate; + const clone = template.content.cloneNode(true); + const messageId = createRandomString(40); + const userId = data.user.name.toLowerCase(); + + const { + header, + platform, + user, + action, + value, + 'actual-message': message + } = Object.fromEntries( + [...clone.querySelectorAll('[class]')] + .map(el => [el.className, el]) + ); + + const classes = ['twitch', 'sub']; + + header.remove(); + message.remove(); + + + user.innerHTML = `${data.user.name}`; + + action.innerHTML = ` subscribed for `; + + var months = data.duration_months > 1 ? 'months' : 'month'; + var tier = data.is_prime ? 'Prime' : 'Tier '+Math.floor(data.sub_tier/1000); + + value.innerHTML = `${data.duration_months} ${months} (${tier})`; + + addEventItem('twitch', clone, classes, userId, messageId); +} + + + +async function twitchReSubMessage(data) { + + if (showTwitchSubs == false) return; + + const template = eventTemplate; + const clone = template.content.cloneNode(true); + const messageId = createRandomString(40); + const userId = data.user.name.toLowerCase(); + + const { + header, + platform, + user, + action, + value, + 'actual-message': message + } = Object.fromEntries( + [...clone.querySelectorAll('[class]')] + .map(el => [el.className, el]) + ); + + const classes = ['twitch', 'resub']; + + const [fullmessage] = await Promise.all([ + getTwitchEmotesOnParts(data) + ]); + + header.remove(); + + + user.innerHTML = `${data.user.name}`; + + action.innerHTML = ` subscribed for `; + + var months = data.cumulativeMonths > 1 ? 'months' : 'month'; + var tier = data.isPrime ? 'Prime' : 'Tier '+Math.floor(data.subTier/1000); + + value.innerHTML = `${data.cumulativeMonths} ${months} (${tier})`; + + message.innerHTML = fullmessage; + + addEventItem('twitch', clone, classes, userId, messageId); +} + + + +async function twitchGiftMessage(data) { + + const isSub = showTwitchSubs === false; + const isGift = showTwitchGiftedSubs === false; + const isGiftTrain = showTwitchGiftedSubsUserTrain === false; + + if ( + (!data.fromCommunitySubGift && (isSub || isGift)) || + (data.fromCommunitySubGift && (isSub || isGiftTrain)) + ) { + return; + } + + const template = eventTemplate; + const clone = template.content.cloneNode(true); + const messageId = createRandomString(40); + const userId = data.user.name.toLowerCase(); + + const { + header, + platform, + user, + action, + value, + 'actual-message': message + } = Object.fromEntries( + [...clone.querySelectorAll('[class]')] + .map(el => [el.className, el]) + ); + + const classes = ['twitch', 'giftsub']; + + header.remove(); + message.remove(); + + + user.innerHTML = `${data.user.name}`; + + var months = data.durationMonths > 1 ? 'months' : 'month'; + action.innerHTML = ` gifted ${data.durationMonths} ${months} subscription (Tier ${Math.floor(data.subTier/1000)}) to `; + + value.innerHTML = `${data.recipient.name}`; + + addEventItem('twitch', clone, classes, userId, messageId); +} + + + +async function twitchGiftBombMessage(data) { + + if (showTwitchSubs == false || showTwitchMassGiftedSubs == false) return; + + const template = eventTemplate; + const clone = template.content.cloneNode(true); + const messageId = createRandomString(40); + const userId = data.user.name.toLowerCase(); + + const { + header, + platform, + user, + action, + value, + 'actual-message': message + } = Object.fromEntries( + [...clone.querySelectorAll('[class]')] + .map(el => [el.className, el]) + ); + + const classes = ['twitch', 'giftbomb']; + + header.remove(); + value.remove(); + + + user.innerHTML = `${data.user.name}`; + + var subs = data.total > 1 ? 'subs' : 'sub'; + action.innerHTML = ` gifted ${data.total} Tier ${Math.floor(data.sub_tier/1000)} ${subs} to the Community`; + + message.innerHTML = `They've gifted a total of ${data.cumulative_total} subs`; + + addEventItem('twitch', clone, classes, userId, messageId); +} + + + +async function twitchRaidMessage(data) { + + if (showTwitchRaids == false) return; + + const template = eventTemplate; + const clone = template.content.cloneNode(true); + const messageId = createRandomString(40); + const userId = data.from_broadcaster_user_name.toLowerCase(); + + const { + header, + platform, + user, + action, + value, + 'actual-message': message + } = Object.fromEntries( + [...clone.querySelectorAll('[class]')] + .map(el => [el.className, el]) + ); + + const classes = ['twitch', 'raid']; + + header.remove(); + message.remove(); + + + user.innerHTML = `${data.from_broadcaster_user_name}`; + + var viewers = data.viewers > 1 ? 'viewers' : 'viewer'; + action.innerHTML = ` raided the channel with `; + value.innerHTML = `${data.viewers} ${viewers}`; + + addEventItem('twitch', clone, classes, userId, messageId); +} + + + +async function twitchChatMessageDeleted(data) { + document.getElementById(data.messageId)?.remove(); +} + + + +async function twitchUserBanned(data) { + chatContainer.querySelectorAll(`[data-user="${data.user_login}"]`).forEach(element => { + element.remove(); + }); +} + + + +async function twitchChatClearMessages() { + chatContainer.querySelectorAll(`.item.twitch`).forEach(element => { + element.remove(); + }); +} + + + +async function twitchUpdateStatistics(data) { + if (showPlatformStatistics == false || showTwitchViewers == false) return; + + const viewers = DOMPurify.sanitize(data.viewerCount); + document.querySelector('#statistics #twitch .viewers span').textContent = formatNumber(viewers); +} + + + + + + + + + + + + + +// --------------------------- +// TWITCH UTILITY FUNCTIONS + +async function getTwitchAvatar(user) { + if (twitchAvatars.has(user)) { + console.debug(`Twitch avatar found for ${user}!`); + return twitchAvatars.get(user); + } + + console.debug(`Twitch avatar not found for ${user}! Getting it from DECAPI!`); + + try { + const response = await fetch(`https://decapi.me/twitch/avatar/${user}`); + let avatar = await response.text(); + + if (!avatar) { + avatar = 'https://static-cdn.jtvnw.net/user-default-pictures-uv/cdd517fe-def4-11e9-948e-784f43822e80-profile_image-300x300.png'; + } + + twitchAvatars.set(user, avatar); + return avatar; + } + catch (err) { + console.error(`Failed to fetch avatar for ${user}:`, err); + return 'https://static-cdn.jtvnw.net/user-default-pictures-uv/cdd517fe-def4-11e9-948e-784f43822e80-profile_image-300x300.png'; + } +} + + + +async function getTwitchEmotes(data) { + const message = data.message.message; + const emotes = data.emotes.sort((a, b) => b.startIndex - a.startIndex); + + const words = message.split(" ").map(word => { + const emote = emotes.find(e => e.name === word); + return emote + ? `${emote.name}` + : word; + }); + + return words.join(" "); +} + + + +async function getTwitchBadges(data) { + const badges = data.message.badges; + return badges + .map(badge => ``) + .join(''); +} + + + +async function getTwitchEmotesOnParts(data) { + let messageText = data.text; + + for (const part of data.parts) { + if (part.type === 'emote') { + const emoteName = part.text; + const emoteUrl = part.imageUrl; + const emoteHTML = `${emoteName}`; + + const escaped = emoteName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + + let pattern; + + if (/^\w+$/.test(emoteName)) { + pattern = `\\b${escaped}\\b`; + } else { + pattern = `(?<=^|[^\\w])${escaped}(?=$|[^\\w])`; + } + + const regex = new RegExp(pattern, 'g'); + messageText = messageText.replace(regex, emoteHTML); + } + } + + return messageText; +} + + + + +async function getTwitchAnnouncementBadges(data) { + const badges = data.user.badges; + return badges + .map(badge => ``) + .join(''); +} + + +async function getTwitchUserPronouns(username) { + if (twitchPronouns.has(username)) { + console.debug(`Pronouns found for ${username}. Getting it from Map...`); + return twitchPronouns.get(username); + } + + console.debug(`Pronouns not found for ${username} in the Map! Retrieving...`); + + try { + const response = await streamerBotClient.getUserPronouns('twitch', username); + + const pronoun = response?.pronoun?.userFound + ? `${response.pronoun.pronounSubject}/${response.pronoun.pronounObject}` + : ''; + + twitchPronouns.set(username, pronoun); + return pronoun; + } + + catch (err) { + console.error(`Couldn't retrieve pronouns for ${username}:`, err); + return ''; + } +} \ No newline at end of file diff --git a/js/modules/youtube/images/logo-youtube.svg b/js/modules/youtube/images/logo-youtube.svg new file mode 100644 index 0000000..68f79f9 --- /dev/null +++ b/js/modules/youtube/images/logo-youtube.svg @@ -0,0 +1,17 @@ + + + + + + + + + \ No newline at end of file diff --git a/js/modules/youtube/module.css b/js/modules/youtube/module.css new file mode 100644 index 0000000..646a36b --- /dev/null +++ b/js/modules/youtube/module.css @@ -0,0 +1,57 @@ +#chat .event.youtube .message { + background: rgba(255,0,0,0.75); +} + +#chat .event.youtube.sticker .header { + padding: 5px 0; + text-align: center; +} +#chat .event.youtube.sticker .header img { + height: 128px; +} + +#chat .item.kick .platform .hidden-platform { + background: rgba(255,0,0,1); +} + + + +#chat .item.youtube span.badge { + display: inline-flex; + justify-content: center; + align-items: center; + vertical-align: top; + color: #FFF; + background: rgba(255,255,255); + width: 22px; + height: 22px; + font-size: 12px; + border-radius: 3px; + margin: 0 1px; +} + +#chat .item.youtube span.badge.owner { + background: #FF0000; +} + +#chat .item.youtube span.mod { + background: #5e84f1; +} + +#chat .item.youtube span.member { + background: #2ba640; +} +#chat .item.youtube span.verified { + background: #aaa; +} + +#chat .item.youtube.streamer .user { + background: #ffd600; + color: #121212 !important; + text-shadow: none; + box-shadow: 2px 2px 5px rgba(0,0,0,0.25); + padding: 0 5px; + border-radius: 5px; +} + +#statistics .platform#youtube { background: #FF0000; } \ No newline at end of file diff --git a/js/modules/youtube/module.js b/js/modules/youtube/module.js new file mode 100644 index 0000000..2838378 --- /dev/null +++ b/js/modules/youtube/module.js @@ -0,0 +1,509 @@ +/* ------------------------ */ +/* YOUTUBE MODULE VARIABLES */ +/* ------------------------ */ + +const showYoutube = getURLParam("showYoutube", true); + +const showYouTubeMessages = getURLParam("showYouTubeMessages", true); +const showYouTubeSuperChats = getURLParam("showYouTubeSuperChats", true); +const showYouTubeSuperStickers = getURLParam("showYouTubeSuperStickers", true); +const showYouTubeSuperStickerGif = getURLParam("showYouTubeSuperStickerGif", true); +const showYouTubeMemberships = getURLParam("showYouTubeMemberships", true); +const showYouTubeGiftMemberships = getURLParam("showYouTubeGiftMemberships", true); +const showYouTubeMembershipsTrain = getURLParam("showYouTubeMembershipsTrain", true); +const showYouTubeStatistics = getURLParam("showYouTubeStatistics", true); + +let youTubeCustomEmotes = []; +let youTubeBTTVEmotes = []; + +userColors.set('youtube', new Map()); + + +// YOUTUBE EVENTS HANDLERS + +const youtubeMessageHandlers = { + 'YouTube.Message': (response) => { + youTubeChatMessage(response.data); + }, + 'YouTube.UserBanned': (response) => { + youTubeUserBanned(response.data); + }, + 'YouTube.SuperChat': (response) => { + youTubeSuperChatMessage(response.data); + }, + 'YouTube.SuperSticker': (response) => { + youTubeSuperStickerMessage(response.data); + }, + 'YouTube.NewSponsor': (response) => { + youTubeNewSponsorMessage(response.data); + }, + 'YouTube.MemberMileStone': (response) => { + youTubeNewSponsorMessage(response.data); + }, + 'YouTube.MembershipGift': (response) => { + youTubeGiftBombMessage(response.data); + }, + 'YouTube.GiftMembershipReceived': (response) => { + youTubeGiftBombReceivedMessage(response.data); + }, + 'YouTube.StatisticsUpdated': (response) => { + youTubeUpdateStatistics(response.data); + } +}; + + + +if (showYoutube) { + + const youtubeStatistics = ` + + `; + + document.querySelector('#statistics').insertAdjacentHTML('beforeend', youtubeStatistics); + + if (showYouTubeStatistics == true) { document.querySelector('#youtube').style.display = ''; } + + registerPlatformHandlersToStreamerBot(youtubeMessageHandlers, '[YouTube]'); + +} + + + + +// --------------------------- +// YOUTUBE EVENT FUNCTIONS + +async function youTubeChatMessage(data) { + + if (showYouTubeMessages == false) return; + if (ignoreUserList.includes(data.user.name.toLowerCase())) return; + if (data.message.startsWith("!") && excludeCommands == true) return; + + const template = chatTemplate; + const clone = template.content.cloneNode(true); + const messageId = data.eventId; + const userId = data.user.id; + + const { + 'first-message': firstMessage, + 'shared-chat': sharedChat, + + header, + timestamp, + platform, + badges, + avatar, + pronouns: pronoun, + user, + + reply, + 'actual-message': message + } = Object.fromEntries( + [...clone.querySelectorAll('[class]')] + .map(el => [el.className, el]) + ); + + const classes = ['youtube', 'chat']; + + const [fullmessage, badgeList] = await Promise.all([ + getYouTubeEmotes(data), + getYouTubeBadges(data) + ]); + + header.remove(); + firstMessage.remove(); + sharedChat.remove(); + reply.remove(); + pronoun.remove(); + + var color = await createRandomColor('youtube', data.user.name); + + user.style.color = color; + user.innerHTML = `${data.user.name}`; + message.innerHTML = fullmessage; + + if (showAvatar) avatar.innerHTML = ``; else avatar.remove(); + if (showBadges) badges.innerHTML = badgeList; else badges.remove(); + + if (data.user.isOwner) { classes.push('streamer'); } + + addMessageItem('youtube', clone, classes, userId, messageId); +} + + + + +async function youTubeSuperChatMessage(data) { + + if (showYouTubeSuperChats == false) return; + + const template = eventTemplate; + const clone = template.content.cloneNode(true); + const messageId = data.eventId; + const userId = data.user.id; + + const { + header, + platform, + user, + action, + value, + 'actual-message': message + } = Object.fromEntries( + [...clone.querySelectorAll('[class]')] + .map(el => [el.className, el]) + ); + + const classes = ['youtube', 'superchat']; + + header.remove(); + + + user.innerHTML = `${data.user.name}`; + action.innerHTML = ` superchatted `; + value.innerHTML = `${data.amount}`; + + var fullmessage = await getYouTubeEmotes(data); + message.innerHTML = fullmessage; + + addEventItem('youtube', clone, classes, userId, messageId); +} + + + +async function youTubeSuperStickerMessage(data) { + + if (showYouTubeMemberships == false) return; + + const template = eventTemplate; + const clone = template.content.cloneNode(true); + const messageId = data.eventId; + const userId = data.user.id; + + const { + header, + platform, + user, + action, + value, + 'actual-message': message + } = Object.fromEntries( + [...clone.querySelectorAll('[class]')] + .map(el => [el.className, el]) + ); + + const classes = ['youtube', 'sticker']; + + if (showYouTubeSuperStickerGif == true) { + youtubeStickerUrl = await getYouTubeStickerImage(data); + header.innerHTML = ``; + } + else { + header.remove(); + } + + + user.innerHTML = `${data.user.name}`; + action.innerHTML = ` sent a supersticker `; + + value.innerHTML = `(${data.amount})`; + + message.remove(); + + addEventItem('youtube', clone, classes, userId, messageId); +} + + + +async function youTubeNewSponsorMessage(data) { + + if (showYouTubeMemberships == false) return; + + const template = eventTemplate; + const clone = template.content.cloneNode(true); + const messageId = data.eventId; + const userId = data.user.id; + + const { + header, + platform, + user, + action, + value, + 'actual-message': message + } = Object.fromEntries( + [...clone.querySelectorAll('[class]')] + .map(el => [el.className, el]) + ); + + const classes = ['youtube', 'sponsor']; + + header.remove(); + + + user.innerHTML = `${data.user.name}`; + action.innerHTML = ` became a member `; + + var months = data.months > 1 ? 'months' : 'month'; + value.innerHTML = `${data.months || 1} ${months}`; + + var fullmessage = await getYouTubeEmotes(data); + message.innerHTML = fullmessage; + + addEventItem('youtube', clone, classes, userId, messageId); +} + + + +async function youTubeGiftBombMessage(data) { + + if (showYouTubeMemberships == false || showYouTubeGiftMemberships == false) return; + + const template = eventTemplate; + const clone = template.content.cloneNode(true); + const messageId = data.eventId; + const userId = data.user.id; + + const { + header, + platform, + user, + action, + value, + 'actual-message': message + } = Object.fromEntries( + [...clone.querySelectorAll('[class]')] + .map(el => [el.className, el]) + ); + + const classes = ['youtube', 'giftbomb']; + + header.remove(); + + + user.innerHTML = `${data.user.name}`; + action.innerHTML = ` gifted `; + + var count = data.count > 1 ? 'memberships' : 'membership'; + value.innerHTML = `${data.count} ${count} (Tier ${data.tier}) to the channel`; + + message.remove(); + + addEventItem('youtube', clone, classes, userId, messageId); +} + + + +async function youTubeGiftBombReceivedMessage(data) { + + if (showYouTubeMemberships == false || showYouTubeGiftMemberships == false || showYouTubeMembershipsTrain == false) return; + + const template = eventTemplate; + const clone = template.content.cloneNode(true); + const messageId = data.eventId; + const userId = data.user.id; + + const { + header, + platform, + user, + action, + value, + 'actual-message': message + } = Object.fromEntries( + [...clone.querySelectorAll('[class]')] + .map(el => [el.className, el]) + ); + + const classes = ['youtube', 'giftbomb']; + + header.remove(); + + + user.innerHTML = `${data.user.name}`; + action.innerHTML = ` gifted a membership (Tier ${data.tier}) to `; + value.innerHTML = `${data.gifter.name}`; + + message.remove(); + + addEventItem('youtube', clone, classes, userId, messageId); +} + + + +async function youTubeUserBanned(data) { + chatContainer.querySelectorAll(`[data-user="${data.bannedUser.id}"]:not(.event)`).forEach(element => { + element.remove(); + }); +} + + + +async function youTubeUpdateStatistics(data) { + + if (showPlatformStatistics == false || showYouTubeStatistics == false) return; + + const viewers = DOMPurify.sanitize(data.concurrentViewers); + const likes = DOMPurify.sanitize(data.likeCount); + document.querySelector('#statistics #youtube .viewers span').textContent = formatNumber(viewers); + document.querySelector('#statistics #youtube .likes span').textContent = formatNumber(likes); +} + + + + + + + + + + + + + +// --------------------------- +// YOUTUBE UTILITY FUNCTIONS + + + +async function getYouTubeEmotes(data) { + let message = data.message; + const channelId = data.broadcast?.channelId; + if (!channelId) return message; + + // Load BTTV Emotes if not already loaded + if (youTubeBTTVEmotes.length === 0) { + try { + const res = await fetch(`https://api.betterttv.net/3/cached/users/youtube/${channelId}`); + const emoteData = await res.json(); + console.debug('Getting YouTube BTTV Channel Emotes', `https://api.betterttv.net/3/cached/users/youtube/${channelId}`, emoteData); + youTubeBTTVEmotes = [ + ...(emoteData.sharedEmotes || []), + ...(emoteData.channelEmotes || []) + ]; + } catch (err) { + console.warn("[YouTube] Failed to load BTTV emotes:", err); + } + } + + // Create an Emote Map + const emoteMap = new Map(); + + // BTTV emotes + for (const emote of youTubeBTTVEmotes) { + const imageUrl = `https://cdn.betterttv.net/emote/${emote.id}/1x`; + const emoteElement = `${emote.code}`; + emoteMap.set(emote.code, { html: emoteElement, raw: emote.code }); + } + + // YouTube emotes (ex: :hand-pink-waving:) + if (data.emotes) { + for (const emote of data.emotes) { + const emoteElement = `${emote.name}`; + emoteMap.set(emote.name, { html: emoteElement, raw: emote.name }); + } + } + + // Custom Member Emotes + if (data.user.isSponsor === true || data.user.isOwner === true) { + for (const [name, url] of Object.entries(youTubeCustomEmotes)) { + const emoteElement = `${name}`; + emoteMap.set(`:${name}:`, { html: emoteElement, raw: `:${name}:` }); + } + } + + // DOMParser just to replace the text nodes + const parser = new DOMParser(); + const doc = parser.parseFromString(`
${message}
`, 'text/html'); + const container = doc.body.firstChild; + + function escapeRegex(string) { + return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + } + + function replaceEmotesInText(text) { + // Sort them DESC to avoid conflicts with similar names + const sorted = Array.from(emoteMap.values()).sort((a, b) => b.raw.length - a.raw.length); + + for (const { raw, html } of sorted) { + const escaped = escapeRegex(raw); + + // Emotes with colons: :emote: → allow colons + const isDelimited = raw.startsWith(':') && raw.endsWith(':'); + const regex = isDelimited + ? new RegExp(escaped, 'g') + : new RegExp(`(?', + isSponsor && '', + isModerator && '', + isOwner && '', + ].filter(Boolean).join(''); + + return badgesHTML; +} \ No newline at end of file diff --git a/js/sb.js b/js/sb.js new file mode 100644 index 0000000..293c2a8 --- /dev/null +++ b/js/sb.js @@ -0,0 +1,67 @@ +/* ----------------------- */ +/* STREAMER.BOT CONNECTION */ +/* ----------------------- */ + +const streamerBotServerAddress = getURLParam("streamerBotServerAddress", "127.0.0.1"); +const streamerBotServerPort = getURLParam("streamerBotServerPort", "8080"); + +const showSpeakerbot = getURLParam("showSpeakerbot", true); +const speakerBotServerAddress = getURLParam("speakerBotServerAddress", "127.0.0.1"); +const speakerBotServerPort = getURLParam("speakerBotServerPort", "7580"); +const speakerBotChatRead = getURLParam("speakerBotChatRead", false); +const speakerBotEventRead = getURLParam("speakerBotEventRead", false); +const speakerBotVoiceAlias = getURLParam("speakerBotVoiceAlias", "Maria"); +const speakerBotChatTemplate = getURLParam("speakerBotChatTemplate", "{user} said {message}"); + +if (showSpeakerbot == true ) { + + const speakerBotClient = new SpeakerBotClient({ + host: speakerBotServerAddress, + port: speakerBotServerPort, + voiceAlias: speakerBotVoiceAlias, + onConnect: (data) => { + + notifySuccess({ + title: 'Connected to Speaker.bot', + text: `` + }); + }, + }); + +} + +const streamerBotClient = new StreamerbotClient({ + host: streamerBotServerAddress, + port: streamerBotServerPort, + + onConnect: (data) => { + console.debug(`[ChatRD][Overlay] Connected to Streamer.bot successfully!`); + + notifySuccess({ + title: 'Connected to Streamer.bot', + text: `` + }); + }, +}); + +function getURLParam(param, defaultValue) { + const urlParams = new URLSearchParams(window.location.search); + const value = urlParams.get(param); + + if (value === 'true') return true; + if (value === 'false') return false; + if (value === null) return defaultValue; + + return value; +} + +function registerPlatformHandlersToStreamerBot(handlers, logPrefix = '') { + for (const [event, handler] of Object.entries(handlers)) { + streamerBotClient.on(event, (...args) => { + if (logPrefix) { + console.debug(`${logPrefix} ${event}`, args[0]); + } + handler(...args); + }); + } +} \ No newline at end of file diff --git a/js/settings.js b/js/settings.js index 46c69b4..325e8db 100644 --- a/js/settings.js +++ b/js/settings.js @@ -1,452 +1,499 @@ -let streamerBotClient; let streamerBotConnected = false; +let kickWebSocket = null; +let tikfinityWebSocket = null; +let speakerBotClient = null; -async function saveSettingsToLocalStorage() { - const checkboxes = document.querySelectorAll("input[type=checkbox]:not(.avoid)"); - const textfields = document.querySelectorAll("input[type=text]:not(.avoid)"); - const numberfields = document.querySelectorAll("input[type=number]:not(.avoid)"); - const colorfields = document.querySelectorAll("input[type=color]:not(.avoid)"); - const selects = document.querySelectorAll("select:not(.avoid)"); - - const hiddenField = document.querySelector("textarea[name=youTubeCustomEmotes]:not(.avoid)"); +/* ------------------------- + Salvar configurações no localStorage +-------------------------- */ +function saveSettingsToLocalStorage() { + const checkboxes = document.querySelectorAll("input[type=checkbox]:not(.avoid)"); + const textfields = document.querySelectorAll("input[type=text]:not(.avoid)"); + const numberfields = document.querySelectorAll("input[type=number]:not(.avoid)"); + const colorfields = document.querySelectorAll("input[type=color]:not(.avoid)"); + const selects = document.querySelectorAll("select:not(.avoid)"); + const ranges = document.querySelectorAll("input[type=range]:not(.avoid)"); + const settings = {}; - const ranges = document.querySelectorAll("input[type=range]:not(.avoid)"); + checkboxes.forEach(cb => settings[cb.name] = cb.checked); + ranges.forEach(r => settings[r.name] = r.value); + textfields.forEach(tf => settings[tf.name] = tf.value); + numberfields.forEach(nf => settings[nf.name] = nf.value); + colorfields.forEach(cf => settings[cf.name] = cf.value); + selects.forEach(s => settings[s.name] = s.value); - const settings = {}; + localStorage.setItem("chatrdWidgetSettings", JSON.stringify(settings)); - checkboxes.forEach((checkbox) => { - settings[checkbox.name] = checkbox.checked; - }); - ranges.forEach((range) => { - settings[range.name] = range.value; - }); - textfields.forEach((textfield) => { - settings[textfield.name] = textfield.value; - }); - numberfields.forEach((numberfield) => { - settings[numberfield.name] = numberfield.value; - }); - colorfields.forEach((colorfield) => { - settings[colorfield.name] = colorfield.value; - }); - selects.forEach((select) => { - settings[select.name] = select.value; - }); - - localStorage.setItem("chatWidgetSettings", JSON.stringify(settings)); - - if (streamerBotConnected == true) { - streamerBotClient.doAction( - { name : "YouTube Custom Emotes" }, - { - "chatrdytcustomemotes": JSON.stringify(hiddenField.value.trim()), - } - ).then( (setglobals) => { - console.debug('Saving YouTube Emotes from Streamer.Bot', setglobals); - }); - } + // Salva emotes no Streamer.bot + try { + const youtubeMemberEmotes = document.querySelector("textarea[name=youTubeCustomEmotes]:not(.avoid)"); + youtubeSaveMemberEmotes(JSON.parse(youtubeMemberEmotes.value)); + } + catch (err) { + console.error("[ChatRD] Emotes JSON inválido", err); + } + generateUrl(); } - +/* ------------------------- + Carregar configurações do localStorage +-------------------------- */ async function loadSettingsFromLocalStorage() { - const saved = localStorage.getItem("chatWidgetSettings"); - if (!saved) return; + const saved = localStorage.getItem("chatrdWidgetSettings"); + if (!saved) return; - const settings = JSON.parse(saved); - console.log(settings); + const settings = JSON.parse(saved); - Object.keys(settings).forEach((key) => { - const input = document.querySelector(`[name="${key}"]`); - if (input) { - if (input.type === "checkbox") { - input.checked = settings[key]; - } - else { - input.value = settings[key]; - } - } - }); + Object.keys(settings).forEach(key => { + const input = document.querySelector(`[name="${key}"]`); + if (input) { + if (input.type === "checkbox") { + input.checked = settings[key]; + } else { + input.value = settings[key]; + } + } + }); + document.querySelector('#font-value').textContent = Math.floor(document.querySelector('#font-slider').value * 100) + '%'; + document.querySelector('#bg-opacity-value').textContent = Math.floor(document.querySelector('#bg-opacity-slider').value * 100) + '%'; - document.querySelector('#font-value').textContent = Math.floor(document.querySelector('#font-slider').value * 100) + '%'; + youtubeLoadMemberEmotes().then(settings => { + if (settings) { + const youtubeMemberEmotes = document.querySelector("textarea[name=youTubeCustomEmotes]:not(.avoid)"); + console.log('[ChatRD][Settings] YouTube Member Emotes Loaded', settings); + youtubeMemberEmotes.value = JSON.stringify(settings); + populateEmoteList(); + } + }); +} +/* ------------------------- + Configurar eventos para salvar mudanças +-------------------------- */ +function pushChangeEvents() { + const checkboxes = document.querySelectorAll("input[type=checkbox]:not(.avoid)"); + const textfields = document.querySelectorAll("input[type=text]:not(.avoid)"); + const numberfields = document.querySelectorAll("input[type=number]:not(.avoid)"); + const colorfields = document.querySelectorAll("input[type=color]:not(.avoid)"); + const selects = document.querySelectorAll("select:not(.avoid)"); + const ranges = document.querySelectorAll("input[type=range]:not(.avoid)"); - var streamerBotServerAddress = document.querySelector('input[type=text][name=streamerBotServerAddress]').value; - var streamerBotServerPort = document.querySelector('input[type=text][name=streamerBotServerPort]').value; + [...checkboxes, ...textfields, ...numberfields, ...colorfields, ...selects, ...ranges].forEach(el => { + el.addEventListener('change', saveSettingsToLocalStorage); + el.addEventListener('input', saveSettingsToLocalStorage); + }); - streamerBotClient = new StreamerbotClient({ - host: streamerBotServerAddress, - port: streamerBotServerPort, - onConnect: (data) => { - streamerBotConnected = true; + document.querySelector('#font-slider').addEventListener('input', function () { + document.querySelector('#font-value').textContent = Math.floor(this.value * 100) + '%'; + }); - document.querySelector('#memberemotesbstatus').classList.remove('offline'); - document.querySelector('#memberemotesbstatus').classList.add('online'); - document.querySelector('#memberemotesbstatus span').textContent = 'Streamer.Bot is Online!'; + document.querySelector('#bg-opacity-slider').addEventListener('input', function () { + document.querySelector('#bg-opacity-value').textContent = Math.floor(this.value * 100) + '%'; + }); +} - streamerBotClient.getGlobals().then( (getglobals) => { - const settings = JSON.parse(getglobals.variables.chatrdytcustomemotes.value); - console.debug('Getting YouTube Emotes from Streamer.Bot', settings); - const textarea = document.querySelector("textarea[name=youTubeCustomEmotes]"); - textarea.value = settings; - - populateEmoteList(); - }); - - }, - onDisconnect: () => { - console.error('Streamer.bot Disconnected!'); - - streamerBotConnected = false; - - document.querySelector('#memberemotesbstatus').classList.remove('online'); - document.querySelector('#memberemotesbstatus').classList.add('offline'); - document.querySelector('#memberemotesbstatus span').textContent = 'Streamer.Bot is Offline!'; - } - }); +/* ------------------------- + Gerar URL de preview +-------------------------- */ +function generateUrl() { + const outputField = document.getElementById("outputUrl"); + outputField.value = ''; + const baseUrlObj = new URL(window.location.href); + baseUrlObj.pathname = baseUrlObj.pathname.replace(/index\.html$/, "chat.html"); + const baseUrl = baseUrlObj.toString(); + + const checkboxes = document.querySelectorAll("input[type=checkbox]:not(.avoid)"); + const textfields = document.querySelectorAll("input[type=text]:not(.avoid)"); + const numberfields = document.querySelectorAll("input[type=number]:not(.avoid)"); + const colorfields = document.querySelectorAll("input[type=color]:not(.avoid)"); + const selects = document.querySelectorAll("select:not(.avoid)"); + const ranges = document.querySelectorAll("input[type=range]:not(.avoid)"); + + const params = new URLSearchParams(); + + selects.forEach(s => params.set(s.name, s.value)); + ranges.forEach(r => params.set(r.name, r.value)); + checkboxes.forEach(cb => params.set(cb.name, cb.checked)); + colorfields.forEach(cf => params.set(cf.name, cf.value)); + textfields.forEach(tf => params.set(tf.name, tf.value)); + numberfields.forEach(nf => params.set(nf.name, nf.value)); + + outputField.value = baseUrl + '?' + params.toString(); + document.querySelector('#preview iframe').src = 'chat.html?' + params.toString(); +} + +/* ------------------------- + Copiar URL para clipboard +-------------------------- */ +function copyUrl() { + const output = document.getElementById("outputUrl"); + const value = output.value; + const button = document.querySelector('.url-bar button'); + const buttonDefaultText = 'Copy URL'; + + navigator.clipboard.writeText(value).then(() => { + button.textContent = 'ChatRD URL Copied!'; + button.style.backgroundColor = "#00dd63"; + + setTimeout(() => { + button.textContent = buttonDefaultText; + button.removeAttribute('style'); + }, 3000); + }).catch(err => { + console.error("Failed to copy: ", err); + }); +} + +/* ------------------------- + Mostrar/esconder plataformas +-------------------------- */ +function setupPlatformToggles() { + const platforms = document.querySelectorAll('.platform'); + + platforms.forEach(platform => { + const platformId = platform.id; + const toggleName = `show${capitalize(platformId)}`; + const toggle = platform.querySelector(`input[name="${toggleName}"]`); + const setupDiv = platform.querySelector('.setup'); + + if (toggle && setupDiv) { + // Removido: initializeTransitionStyles(setupDiv); + + // Defina o overflow no CSS ou aqui, se preferir + setupDiv.style.overflow = 'hidden'; + setupDiv.style.transition = 'max-height 0.4s ease, opacity 0.4s ease'; + + setVisible(setupDiv, toggle.checked); + + toggle.removeEventListener('change', toggle._handler || (() => { })); + + const handler = () => setVisible(setupDiv, toggle.checked); + toggle._handler = handler; + toggle.addEventListener('change', handler); + } + }); + + function setVisible(element, visible) { + if (visible) { + // Remove 'display: none' para que a altura possa ser calculada + element.style.display = 'block'; + + // Força o elemento a iniciar com altura e opacidade zero + element.style.maxHeight = '0px'; + element.style.opacity = '0'; + element.offsetHeight; // Força a renderização + + // Inicia a transição para a altura real e opacidade completa + element.style.maxHeight = element.scrollHeight + 'px'; + element.style.opacity = '1'; + + // Remove os estilos após a transição de abertura + element.addEventListener('transitionend', function handler() { + element.style.maxHeight = null; + element.style.opacity = null; + element.removeEventListener('transitionend', handler); + }); + + } + + else { + // Define o maxHeight para a altura atual antes de iniciar a transição de fechamento + element.style.maxHeight = element.scrollHeight + 'px'; + element.offsetHeight; // Força a renderização + + // Inicia a transição para fechar o elemento + element.style.maxHeight = '0px'; + element.style.opacity = '0'; + + // Esconde o elemento com 'display: none' após a transição + setTimeout(() => { + if (element.style.opacity === '0') { + element.style.display = 'none'; + } + }, 400); // O tempo precisa ser o mesmo da transição (0.4s) + } + } + + // A função initializeTransitionStyles() foi removida. + // O estilo de transição foi movido para a função principal para ser definido uma única vez. + + function capitalize(str) { + return str.charAt(0).toUpperCase() + str.slice(1); + } +} + +/* ------------------------- + Navegação no footer +-------------------------- */ +function setupFooterNavBar() { + document.querySelectorAll('.nav-bar a').forEach(anchor => { + anchor.addEventListener('click', function (e) { + e.preventDefault(); + const targetId = this.getAttribute('href'); + if (!targetId || !targetId.startsWith('#')) return; + + const targetElement = document.querySelector(targetId); + if (targetElement) { + const offset = 20; + const y = targetElement.getBoundingClientRect().top + window.scrollY - offset; + window.scrollTo({ top: y, behavior: 'smooth' }); + } + }); + }); +} + +/* ------------------------- + Modal para adicionar emotes +-------------------------- */ +function setupAddEmoteModal() { + const modal = document.getElementById("addEmoteModal"); + const nameInput = document.getElementById("newEmoteName"); + const urlInput = document.getElementById("newEmoteURL"); + const confirmBtn = document.getElementById("confirmAddEmote"); + const cancelBtn = document.getElementById("cancelAddEmote"); + const addButton = document.querySelector("#addEmoteButton"); + const textarea = document.querySelector("textarea[name=youTubeCustomEmotes]"); + + if (!modal || !addButton || !textarea) return; + + // ESC global → aciona cancelBtn + document.addEventListener("keydown", (event) => { + if (event.key === "Escape" && !modal.classList.contains("hidden")) { + cancelBtn.click(); + } + }); + + // ENTER nos inputs → aciona confirmBtn + [nameInput, urlInput].forEach(input => { + input.addEventListener("keydown", (event) => { + if (event.key === "Enter") { + event.preventDefault(); // evita submit/form + confirmBtn.click(); + } + }); + }); + + addButton.onclick = (event) => { + event.preventDefault(); + if (streamerBotConnected) { + nameInput.value = ""; + urlInput.value = ""; + modal.classList.remove("hidden"); + nameInput.focus(); + } else { + alert("Streamer.bot is Offline!"); + } + }; + + cancelBtn.onclick = (e) => { + e.preventDefault(); + modal.classList.add("hidden"); + }; + + confirmBtn.onclick = (e) => { + e.preventDefault(); + const name = nameInput.value.trim(); + const url = urlInput.value.trim(); + + if (!name || !url) { + alert("Both fields are required."); + return; + } + + let emotes; + try { + emotes = JSON.parse(textarea.value); + } catch (err) { + console.error("Invalid JSON", err); + alert("Emote data is invalid."); + return; + } + + if (emotes[name]) { + alert(`Emote "${name}" already exists.`); + return; + } + + emotes[name] = url; + textarea.value = JSON.stringify(emotes); + modal.classList.add("hidden"); + populateEmoteList(); + }; } -async function pushChangeEvents() { - const checkboxes = document.querySelectorAll("input[type=checkbox]:not(.avoid)"); - const textfields = document.querySelectorAll("input[type=text]:not(.avoid)"); - const numberfields = document.querySelectorAll("input[type=number]:not(.avoid)"); - const colorfields = document.querySelectorAll("input[type=color]:not(.avoid)"); - const selects = document.querySelectorAll("select:not(.avoid)"); +/* ------------------------- + Lista de emotes +-------------------------- */ +function populateEmoteList() { + const textarea = document.querySelector("textarea[name=youTubeCustomEmotes]"); + const emoteList = document.querySelector("#youtube .emote-list"); + if (!textarea || !emoteList) return; - const ranges = document.querySelectorAll("input[type=range]:not(.avoid)"); + emoteList.querySelectorAll(".emote-item").forEach(item => { + if (item.querySelector("button")?.id !== "addEmoteButton") { + item.remove(); + } + }); - checkboxes.forEach((checkbox) => { - checkbox.addEventListener('change', () => { - generateUrl(); - saveSettingsToLocalStorage(); - }); - }); - textfields.forEach((textfield) => { - textfield.addEventListener('input', () => { - generateUrl(); - saveSettingsToLocalStorage(); - }); - }); - numberfields.forEach((numberfield) => { - numberfield.addEventListener('input', () => { - generateUrl(); - saveSettingsToLocalStorage(); - }); - }); - colorfields.forEach((colorfield) => { - colorfield.addEventListener('change', () => { - generateUrl(); - saveSettingsToLocalStorage(); - }); - }); - selects.forEach((select) => { - select.addEventListener('change', () => { - generateUrl(); - saveSettingsToLocalStorage(); - }); - }); - textfields.forEach((textfield) => { - textfield.addEventListener('input', () => { - generateUrl(); - saveSettingsToLocalStorage(); - }); - }); + let emotes; + try { + emotes = JSON.parse(textarea.value); + } catch (e) { + console.error("[ChatRD][Settings] Invalid JSON in YouTube Emotes textarea", e); + return; + } - ranges.forEach((range) => { - range.addEventListener('change', () => { - generateUrl(); - saveSettingsToLocalStorage(); - }); - }); + const addButtonSpan = emoteList.querySelector("#addEmoteButton")?.parentElement; - document.querySelector('#font-slider').addEventListener('input', function () { - document.querySelector('#font-value').textContent = Math.floor(this.value * 100) + '%'; - }); + for (const [emoteName, emoteUrl] of Object.entries(emotes)) { + const span = document.createElement("span"); + span.classList.add("emote-item"); + span.innerHTML = ` + + ${emoteName} + + `; - document.querySelector('#bg-opacity-slider').addEventListener('input', function () { - document.querySelector('#bg-opacity-value').textContent = this.value; - }); + span.querySelector(".delete").addEventListener("click", () => { + if (confirm(`Are you sure you want to delete '${emoteName}'?`)) { + delete emotes[emoteName]; + textarea.value = JSON.stringify(emotes); + populateEmoteList(); + } + }); + + emoteList.insertBefore(span, addButtonSpan || null); + } + + saveSettingsToLocalStorage(); +} + +/* ------------------------- + Funções YouTube <-> Streamer.bot +-------------------------- */ +function youtubeSaveMemberEmotes(data) { + if (!streamerBotClient) return; + const json = JSON.stringify(data); + streamerBotClient.doAction({ name: "[YouTube] Member Emotes" }, { + "chatrdytcustomemotes": json, + }).then((res) => { + console.debug('[ChatRD][Settings] Saving YouTube Member Emotes... ', res); + }); +} + +function youtubeLoadMemberEmotes() { + if (!streamerBotClient) return Promise.resolve(null); + return streamerBotClient.getGlobals().then((globals) => { + console.debug('[ChatRD][Settings] Loading Global Vars...', globals); + const emoteglobal = globals.variables?.chatrdytcustomemotes; + if (!emoteglobal) { + console.warn('[ChatRD][Settings] Global variable "chatrdytcustomemotes" not found.'); + return null; + } + try { + return JSON.parse(emoteglobal.value); + } catch (e) { + console.error('[ChatRD][Settings] Failed to parse YouTube Member Emote JSON', e); + return null; + } + }); +} + +/* ------------------------- + Conexão com Streamer.bot +-------------------------- */ +function streamerBotConnect() { + const streamerBotStatus = document.getElementById('streamerBotStatus'); + + const streamerBotServerAddress = document.querySelector('input[type=text][name=streamerBotServerAddress]').value; + const streamerBotServerPort = document.querySelector('input[type=text][name=streamerBotServerPort]').value; + + streamerBotClient = new StreamerbotClient({ + host: streamerBotServerAddress, + port: streamerBotServerPort, + onConnect: () => { + console.debug(`[ChatRD][Settings] Connected to Streamer.bot successfully!`); + streamerBotConnected = true; + + streamerBotStatus.classList.add('connected'); + streamerBotStatus.querySelector('small').textContent = `Connected`; + + loadSettingsFromLocalStorage(); + generateUrl(); + pushChangeEvents(); + setupFooterNavBar(); + setupAddEmoteModal(); + setupPlatformToggles(); + + }, + onDisconnect: () => { + streamerBotStatus.classList.remove('connected'); + streamerBotStatus.querySelector('small').textContent = `Awaiting for connection`; + streamerBotConnected = false; + console.debug(`[ChatRD][Settings] Streamer.bot Disconnected!`); + } + }); } -async function generateUrl() { - document.getElementById("outputUrl").value = ''; +async function speakerBotConnection() { + const speakerBotStatus = document.getElementById('speakerBotStatus'); - - var baseUrl = 'https://vortisrd.github.io/chatrd/chat.html'; - - const checkboxes = document.querySelectorAll("input[type=checkbox]:not(.avoid)"); - const textfields = document.querySelectorAll("input[type=text]:not(.avoid)"); - const numberfields = document.querySelectorAll("input[type=number]:not(.avoid)"); - const colorfields = document.querySelectorAll("input[type=color]:not(.avoid)"); - const selects = document.querySelectorAll("select:not(.avoid)"); + const speakerBotServerAddress = document.querySelector('input[type=text][name=speakerBotServerAddress]').value; + const speakerBotServerPort = document.querySelector('input[type=text][name=speakerBotServerPort]').value; + const speakerBotVoiceAlias = document.querySelector('input[type=text][name=speakerBotVoiceAlias]').value; - const ranges = document.querySelectorAll("input[type=range]:not(.avoid)"); + const showSpeakerbot = document.querySelector('input[type=checkbox][name=showSpeakerbot]').checked; - const params = new URLSearchParams(); - - selects.forEach((select) => { - params.set(select.name, select.value); - }); - ranges.forEach((range) => { - params.set(range.name, range.value); - }); - checkboxes.forEach((checkbox) => { - params.set(checkbox.name, checkbox.checked); - }); - colorfields.forEach((colorfield) => { - params.set(colorfield.name, colorfield.value); - }); - textfields.forEach((textfield) => { - params.set(textfield.name, textfield.value); - }); - numberfields.forEach((numberfield) => { - params.set(numberfield.name, numberfield.value); - }); + if (!showSpeakerbot) { + // Se não é pra mostrar, desconecta caso esteja ativo + if (speakerBotClient && speakerBotClient.ws && speakerBotClient.ws.readyState !== WebSocket.CLOSED) { + console.log("[ChatRD][Settings] Disconnecting SpeakerBot..."); + speakerBotClient.disconnect(); + } + return; + } - document.getElementById("outputUrl").value = baseUrl + '?' + params.toString(); - document.querySelector('#chat-preview iframe').src = 'chat.html?'+params.toString(); -} + // Se já está conectado ou conectando, não cria outro + if (speakerBotClient && speakerBotClient.ws && speakerBotClient.ws.readyState !== WebSocket.CLOSED) { + console.log("[ChatRD][Settings] SpeakerBot WebSocket is already on!."); + return; + } -async function copyUrl() { + // Cria nova instância + speakerBotClient = new SpeakerBotClient({ + host: speakerBotServerAddress, + port: speakerBotServerPort, + voiceAlias: speakerBotVoiceAlias, - const output = document.getElementById("outputUrl") - const value = output.value; - - const button = document.querySelector('.url-bar button'); - const buttonDefaulText = 'Copy URL'; + onConnect: () => { + speakerBotStatus.classList.add('connected'); + speakerBotStatus.querySelector('small').textContent = `Connected`; + }, - navigator.clipboard.writeText(value) - .then(() => { - - button.textContent = 'ChatRD URL Copied!'; - button.style.backgroundColor = "#00dd63"; - - setTimeout(() => { - button.textContent = buttonDefaulText; - button.removeAttribute('style'); - }, 3000); - }) - .catch(err => { - console.error("Failed to copy: ", err); - }); - -} - - -async function setupAddEmoteModal() { - const modal = document.getElementById("addEmoteModal"); - const nameInput = document.getElementById("newEmoteName"); - const urlInput = document.getElementById("newEmoteURL"); - const confirmBtn = document.getElementById("confirmAddEmote"); - const cancelBtn = document.getElementById("cancelAddEmote"); - const addButton = document.querySelector("#youtube .emote-item:last-child .add"); - const textarea = document.querySelector("textarea[name=youTubeCustomEmotes]"); - - if (!modal || !addButton || !textarea) return; - - // Show modal - addButton.onclick = () => { - if (streamerBotConnected == true) { - nameInput.value = ""; - urlInput.value = ""; - modal.classList.remove("hidden"); - nameInput.focus(); - } - else { - alert("Streamer.bot is Offline!"); - return; - } - }; - - // Cancel - cancelBtn.onclick = () => { - modal.classList.add("hidden"); - }; - - // Confirm - confirmBtn.onclick = () => { - const name = nameInput.value.trim(); - const url = urlInput.value.trim(); - - if (!name || !url) { - alert("Both fields are required."); - return; - } - - let emotes; - try { - emotes = JSON.parse(textarea.value); - } catch (err) { - console.error("Invalid JSON", err); - alert("Emote data is invalid."); - return; - } - - if (emotes[name]) { - alert(`Emote "${name}" already exists.`); - return; - } - - // Add and update - emotes[name] = url; - textarea.value = JSON.stringify(emotes, null, 4); - modal.classList.add("hidden"); - populateEmoteList(); - }; + onDisconnect: () => { + speakerBotStatus.classList.remove('connected'); + speakerBotStatus.querySelector('small').textContent = `Awaiting for connection`; + } + }); } -async function populateEmoteList() { - const textarea = document.querySelector("textarea[name=youTubeCustomEmotes]"); - const emoteList = document.querySelector("#youtube .emote-list"); - - if (!textarea || !emoteList) return; - - const addButtonSpan = emoteList.querySelector(".emote-item:last-child"); - - // Remove all emote items except the add button - emoteList.querySelectorAll(".emote-item").forEach(item => { - if (item !== addButtonSpan) { - item.remove(); - } - }); - - let emotes; - try { - emotes = JSON.parse(textarea.value); - } catch (e) { - console.error("Invalid JSON in YouTube Emotes textarea", e); - return; - } - - // Recreate each emote item - for (const [emoteName, emoteUrl] of Object.entries(emotes)) { - const span = document.createElement("span"); - span.classList.add("emote-item"); - span.innerHTML = ` - - ${emoteName} - - `; - - // Add delete handler directly to the button - const deleteBtn = span.querySelector(".delete"); - deleteBtn.addEventListener("click", () => { - if (confirm(`Are you sure you want to delete '${emoteName}'?`)) { - delete emotes[emoteName]; - textarea.value = JSON.stringify(emotes, null, 4); - populateEmoteList(); // Re-render everything - } - }); - - emoteList.insertBefore(span, addButtonSpan); - } - - setupAddEmoteModal(); - generateUrl(); - saveSettingsToLocalStorage(); -} - - - -const accordionButtons = document.querySelectorAll("button.accordion"); - -accordionButtons.forEach(button => { - button.addEventListener("click", () => { - const targetId = button.getAttribute("data-target"); - const target = document.getElementById(targetId); - const icon = button.querySelector("i"); - - if (!target || !target.classList.contains("accordion-container")) return; - - const isOpen = target.classList.contains("open"); - - // Fecha todos os outros accordions - document.querySelectorAll(".accordion-container.open").forEach(container => { - if (container !== target) { - container.classList.remove("open"); - container.style.maxHeight = null; - - const otherButton = document.querySelector(`button.accordion[data-target="${container.id}"]`); - if (otherButton) { - const otherIcon = otherButton.querySelector("i"); - if (otherIcon) otherIcon.className = "fa-solid fa-chevron-down"; - } - } - }); - - // Alterna o atual - if (!isOpen) { - target.classList.add("open"); - target.style.maxHeight = target.scrollHeight + "px"; - if (icon) icon.className = "fa-solid fa-chevron-up"; - - // Espera a animação terminar para scrollar - target.addEventListener("transitionend", function handler(e) { - if (e.propertyName === "max-height") { - target.removeEventListener("transitionend", handler); - - const offset = target.getBoundingClientRect().top + window.scrollY - 60; - window.scrollTo({ - top: offset, - behavior: "smooth" - }); - } - }); - } - - else { - target.classList.remove("open"); - target.style.maxHeight = null; - if (icon) icon.className = "fa-solid fa-chevron-down"; - } - }); -}); - - - - - - - -window.addEventListener('load', () => { - loadSettingsFromLocalStorage(); - generateUrl(); - pushChangeEvents(); - populateEmoteList(); - - - document.querySelectorAll('.nav-bar a').forEach(anchor => { - anchor.addEventListener('click', function (e) { - e.preventDefault(); - - // Remove todas as classes dos links dentro da nav-bar - document.querySelectorAll('.nav-bar a').forEach(link => { - link.classList.remove('active'); - }); - - this.classList.add('active'); - - const targetId = this.getAttribute('href'); - const targetElement = document.querySelector(targetId); - - if (targetElement) { - - const offset = 60; // ajusta 20px acima - const y = targetElement.getBoundingClientRect().top + window.scrollY - offset; - - window.scrollTo({ - top: y, - behavior: 'smooth' - }); - } - }); - }); +/* ------------------------- + Inicialização +-------------------------- */ +document.addEventListener('DOMContentLoaded', () => { + streamerBotConnect(); + speakerBotConnection(); + const speakerBotSwitcher = document.querySelector('input[type=checkbox][name=showSpeakerbot]'); + speakerBotSwitcher.addEventListener('change', () => { + speakerBotConnection(); + }); }); diff --git a/js/speakerbot.js b/js/speakerbot.js new file mode 100644 index 0000000..a370817 --- /dev/null +++ b/js/speakerbot.js @@ -0,0 +1,139 @@ +/** + * ============================================================================= + * SpeakerBotClient + * ============================================================================= + * Author: Rodrigo Emanuel (VortisRD) + * Created: 2025-08-10 + * Description: + * WebSocket client for connecting to Speaker.bot, sending TTS (text-to-speech) + * messages, and handling reconnection logic automatically. + * + * Usage Example: + * ----------------------------------------------------------------------------- + * const speakerBot = new SpeakerBotClient({ + * host: '127.0.0.1', + * port: 7580, + * voiceAlias: 'Joanna', + * + * onConnect: () => console.log('Connected!'), + * onDisconnect: () => console.log('Disconnected!'), + * onError: (err) => console.error(err), + * onMessage: (msg) => console.log('SpeakerBot says:', msg) + * }); + * + * speakerBot.speak("Hello World!"); + * ----------------------------------------------------------------------------- + * + * Parameters: + * host - IP or hostname of the Speaker.bot server. + * port - Port number for the WebSocket connection. + * reconnectDelay - Time in ms before attempting reconnection. + * voiceAlias - Preferred TTS voice name (string or null). + * onConnect - Callback fired when connection is established. + * onDisconnect - Callback fired when connection is closed. + * onError - Callback fired on connection error. + * onMessage - Callback fired when receiving a message from Speaker.bot. + * + * Dependencies: + * None (uses native WebSocket API) + * + * Notes: + * - Messages sent while disconnected are queued and sent after reconnection. + * - Bad word filter is enabled by default in speak() payload. + * + * License: + * MIT License + * ============================================================================= + */ + + +class SpeakerBotClient { + constructor({ + host = '127.0.0.1', + port = 7580, + reconnectDelay = 10000, + voiceAlias = null, + onConnect = () => {}, + onDisconnect = () => {}, + onError = () => {}, + onMessage = () => {} + } = {}) { + this.host = host; + this.port = port; + this.reconnectDelay = reconnectDelay; + this.voiceAlias = voiceAlias; + this.onConnect = onConnect; + this.onDisconnect = onDisconnect; + this.onError = onError; + this.onMessage = onMessage; + + this.ws = null; + this.queue = []; + this._manualClose = false; // inicia como false + + this.connect(); + } + + get url() { + return `ws://${this.host}:${this.port}/`; + } + + get readyState() { + return this.ws ? this.ws.readyState : WebSocket.CLOSED; + } + + connect() { + this._manualClose = false; // reset da flag + console.log('[SpeakerBot] Connecting to Speaker.bot...'); + this.ws = new WebSocket(this.url); + + this.ws.onopen = () => { + console.log('[SpeakerBot] Connected to Speaker.bot!'); + this.onConnect(); + while (this.queue.length > 0) { + this.ws.send(this.queue.shift()); + } + }; + + this.ws.onmessage = (event) => { + this.onMessage(event.data); + }; + + this.ws.onerror = (error) => { + //console.warn(`[SpeakerBot] Connection error. Reconnecting in ${Math.floor(this.reconnectDelay / 1000)}s...`, error); + //this.onError(error); + }; + + this.ws.onclose = () => { + this.onDisconnect(); + if (!this._manualClose) { + setTimeout(() => this.connect(), this.reconnectDelay); + } + }; + } + + speak(message) { + const payload = { + id: `speak-${Date.now()}`, + request: 'Speak', + message: message, + voice: this.voiceAlias, + badWordFilter: true + }; + + const json = JSON.stringify(payload); + if (this.ws && this.ws.readyState === WebSocket.OPEN) { + this.ws.send(json); + } else { + console.warn(`[SpeakerBot] Not connected yet. Queuing message...`); + this.queue.push(json); + } + } + + disconnect() { + this._manualClose = true; // flag para evitar reconexão automática + if (this.ws) { + this.ws.close(); + } + } +}