From 5008a3950512c5cb9345f7f31cef4833310a2cbe Mon Sep 17 00:00:00 2001 From: Trubbel <10730122+Trubbel@users.noreply.github.com> Date: Fri, 14 Feb 2025 23:05:57 +0100 Subject: [PATCH] =?UTF-8?q?Trubbel=E2=80=99s=20Utilities=202.0.1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Initial release of a new add-on. Just some random things. --- .../clips-videos/most-recent-video.js | 88 +++++ .../clips-videos/set-clip-timestamp.js | 71 ++++ .../features/clips-videos/set-timestamp.js | 89 +++++ .../clips-videos/set-video-timestamp.js | 72 ++++ src/trubbel/features/commands/accountage.js | 37 ++ src/trubbel/features/commands/chatters.js | 38 ++ src/trubbel/features/commands/followage.js | 35 ++ src/trubbel/features/commands/uptime.js | 22 ++ src/trubbel/features/theme/system-theme.js | 81 ++++ src/trubbel/index.js | 50 +++ src/trubbel/logo.png | Bin 0 -> 43543 bytes src/trubbel/manifest.json | 18 + src/trubbel/settings/chat-commands.js | 148 ++++++++ src/trubbel/settings/clips-videos.js | 344 +++++++++++++++++ src/trubbel/settings/directory.js | 195 ++++++++++ src/trubbel/settings/drops-rewards.js | 215 +++++++++++ src/trubbel/settings/player.js | 117 ++++++ src/trubbel/settings/remove-things.js | 248 +++++++++++++ src/trubbel/settings/ui-tweaks.js | 349 ++++++++++++++++++ src/trubbel/settings/whispers.js | 191 ++++++++++ src/trubbel/utils/constants/commands.js | 49 +++ src/trubbel/utils/constants/config.js | 2 + src/trubbel/utils/constants/player-errors.js | 7 + src/trubbel/utils/constants/selectors.js | 4 + src/trubbel/utils/constants/types.js | 6 + src/trubbel/utils/create-notification.js | 69 ++++ src/trubbel/utils/format.js | 75 ++++ src/trubbel/utils/graphql/accountage.gql | 6 + src/trubbel/utils/graphql/chatters.gql | 8 + src/trubbel/utils/graphql/clip_info.gql | 6 + src/trubbel/utils/graphql/video_info.gql | 14 + 31 files changed, 2654 insertions(+) create mode 100644 src/trubbel/features/clips-videos/most-recent-video.js create mode 100644 src/trubbel/features/clips-videos/set-clip-timestamp.js create mode 100644 src/trubbel/features/clips-videos/set-timestamp.js create mode 100644 src/trubbel/features/clips-videos/set-video-timestamp.js create mode 100644 src/trubbel/features/commands/accountage.js create mode 100644 src/trubbel/features/commands/chatters.js create mode 100644 src/trubbel/features/commands/followage.js create mode 100644 src/trubbel/features/commands/uptime.js create mode 100644 src/trubbel/features/theme/system-theme.js create mode 100644 src/trubbel/index.js create mode 100644 src/trubbel/logo.png create mode 100644 src/trubbel/manifest.json create mode 100644 src/trubbel/settings/chat-commands.js create mode 100644 src/trubbel/settings/clips-videos.js create mode 100644 src/trubbel/settings/directory.js create mode 100644 src/trubbel/settings/drops-rewards.js create mode 100644 src/trubbel/settings/player.js create mode 100644 src/trubbel/settings/remove-things.js create mode 100644 src/trubbel/settings/ui-tweaks.js create mode 100644 src/trubbel/settings/whispers.js create mode 100644 src/trubbel/utils/constants/commands.js create mode 100644 src/trubbel/utils/constants/config.js create mode 100644 src/trubbel/utils/constants/player-errors.js create mode 100644 src/trubbel/utils/constants/selectors.js create mode 100644 src/trubbel/utils/constants/types.js create mode 100644 src/trubbel/utils/create-notification.js create mode 100644 src/trubbel/utils/format.js create mode 100644 src/trubbel/utils/graphql/accountage.gql create mode 100644 src/trubbel/utils/graphql/chatters.gql create mode 100644 src/trubbel/utils/graphql/clip_info.gql create mode 100644 src/trubbel/utils/graphql/video_info.gql diff --git a/src/trubbel/features/clips-videos/most-recent-video.js b/src/trubbel/features/clips-videos/most-recent-video.js new file mode 100644 index 0000000..3b11256 --- /dev/null +++ b/src/trubbel/features/clips-videos/most-recent-video.js @@ -0,0 +1,88 @@ +import { MOST_RECENT_CARD_SELECTOR, MOST_RECENT_SELECTOR } from "../../utils/constants/selectors"; +import GET_VIDEO from "../../utils/graphql/video_info.gql"; +const { createElement } = FrankerFaceZ.utilities.dom; + +let lastProcessedVideoId = null; // Tracks the last processed video ID +let processing = false; // Prevents re-entrant calls + +export default async function applyMostRecentVideoTimestamp(ctx) { + if (ctx.router.current?.name === "user" || ctx.router.current?.name === "mod-view") { + if (!ctx.settings.get("addon.trubbel.clip-video.timestamps-most-recent")) return; + + const element = await ctx.site.awaitElement(MOST_RECENT_SELECTOR, document.documentElement, 15000); + if (element) { + const container = await ctx.site.awaitElement(MOST_RECENT_CARD_SELECTOR, document.documentElement, 15000); + if (container) { + if (container && !document.querySelector(".most_recent_video-overlay")) { + lastProcessedVideoId = null; + processing = false; + } + + const videoId = element?.getAttribute("href").split("/videos/")[1]; + if (!videoId) return; + + if (processing) { + ctx.log.info("[Most Recent Video Timestamp] Skipping as processing is already in progress."); + return; + } + + if (videoId === lastProcessedVideoId) { + ctx.log.info("[Most Recent Video Timestamp] Skipping as this video ID is already processed."); + return; + } + + processing = true; + lastProcessedVideoId = videoId; + + try { + const apollo = ctx.resolve("site.apollo"); + if (!apollo) { + ctx.log.error("[Most Recent Video Timestamp] Apollo client not resolved."); + return null; + } + + const result = await apollo.client.query({ + query: GET_VIDEO, + variables: { + id: videoId, + } + }); + + const createdAt = result?.data?.video?.createdAt; + if (createdAt) { + const overlay = createElement("div", { + class: "most_recent_video-overlay", + style: "display: block;" + }); + + const notice = createElement("div", { + class: "most_recent_video-timestamp", + style: "padding: 0px 0.4rem; background: rgba(0, 0, 0, 0.6); color: #fff; font-size: 1.3rem; border-radius: 0.2rem; position: absolute; bottom: 0px; right: 0px; margin: 2px;" + }); + + let relative = ""; + if (ctx.settings.get("addon.trubbel.clip-video.timestamps-relative")) { + relative = `(${ctx.i18n.toRelativeTime(createdAt)})`; + } + const timestamp = ctx.i18n.formatDateTime( + createdAt, + ctx.settings.get("addon.trubbel.clip-video.timestamps-format") + ); + notice.textContent = `${timestamp} ${relative}`; + + overlay.appendChild(notice); + container.appendChild(overlay); + } + } catch (error) { + ctx.log.error("[Most Recent Video Timestamp] Error applying video timestamp:", error); + } finally { + processing = false; + } + } else { + ctx.log.warn("[Most Recent Video Timestamp] container not found."); + } + } else { + ctx.log.warn("[Most Recent Video Timestamp] element not found."); + } + } +} \ No newline at end of file diff --git a/src/trubbel/features/clips-videos/set-clip-timestamp.js b/src/trubbel/features/clips-videos/set-clip-timestamp.js new file mode 100644 index 0000000..8eed316 --- /dev/null +++ b/src/trubbel/features/clips-videos/set-clip-timestamp.js @@ -0,0 +1,71 @@ +import { CLIPS_TIMESTAMP, VODS_TIMESTAMP } from "../../utils/graphql/clip_info.gql"; +import GET_CLIP from "../../utils/graphql/clip_info.gql"; + +let lastProcessedClipId = null; // Tracks the last processed clip ID +let processing = false; // Prevents re-entrant calls + +export default async function setClipTimestamp(ctx) { + if (ctx.router.current?.name === "clip-page" || ctx.router.current?.name === "user-clip") { + if (!ctx.settings.get("addon.trubbel.clip-video.timestamps-clip")) return; + + const clipId = location.hostname === "clips.twitch.tv" + ? location.pathname.slice(1) + : location.pathname.split("/clip/")[1]; + + if (!clipId) { + ctx.log.error("[Clip Timestamp] Unable to get the clip ID."); + return; + } + + if (processing) { + ctx.log.info("[Clip Timestamp] Skipping as processing is already in progress."); + return; + } + + // Skip if the clip ID has already been processed + if (clipId === lastProcessedClipId) { + ctx.log.info("[Clip Timestamp] Skipping as this clip ID is already processed."); + return; + } + + processing = true; + lastProcessedClipId = clipId; + + try { + const element = await ctx.site.awaitElement(`${CLIPS_TIMESTAMP}, ${VODS_TIMESTAMP}`); + if (element) { + const apollo = ctx.resolve("site.apollo"); + if (!apollo) { + ctx.log.error("[Clip Timestamp] Apollo client not resolved."); + return null; + } + + const result = await apollo.client.query({ + query: GET_CLIP, + variables: { slug: clipId }, + }); + + const createdAt = result?.data?.clip?.createdAt; + if (createdAt) { + let relative = ""; + if (ctx.settings.get("addon.trubbel.clip-video.timestamps-relative")) { + relative = `(${ctx.i18n.toRelativeTime(createdAt)})`; + } + const timestamp = ctx.i18n.formatDateTime( + createdAt, + ctx.settings.get("addon.trubbel.clip-video.timestamps-format") + ); + element.textContent = `${timestamp} ${relative}`; + } else { + ctx.log.warn("[Clip Timestamp] No createdAt data found for clip."); + } + } else { + ctx.log.warn("[Clip Timestamp] Clip timestamp element not found."); + } + } catch (error) { + ctx.log.error("[Clip Timestamp] Error applying new clip timestamp:", error); + } finally { + processing = false; // Ensure processing is reset even on error + } + } +} \ No newline at end of file diff --git a/src/trubbel/features/clips-videos/set-timestamp.js b/src/trubbel/features/clips-videos/set-timestamp.js new file mode 100644 index 0000000..a3c5b64 --- /dev/null +++ b/src/trubbel/features/clips-videos/set-timestamp.js @@ -0,0 +1,89 @@ +import { TIMESTAMP_SELECTOR } from "../../utils/constants/selectors"; +import GET_CLIP from "../../utils/graphql/clip_info.gql"; +import GET_VIDEO from "../../utils/graphql/video_info.gql"; + +async function getIdFromURL(ctx) { + if (location.hostname === "clips.twitch.tv" || location.pathname.includes("/clip/")) { + const clipId = location.hostname === "clips.twitch.tv" + ? location.pathname.slice(1) + : location.pathname.split("/clip/")[1]; + + if (clipId) { + if (!ctx.settings.get("addon.trubbel.clip-video.timestamps-clip")) return; + return { type: "clip", id: clipId }; + } + } + + const videoMatch = location.pathname.match(/\/videos\/(\d+)/); + if (videoMatch) { + if (!ctx.settings.get("addon.trubbel.clip-video.timestamps-video")) return; + return { type: "video", id: videoMatch[1] }; + } + + return null; +} + +async function fetchCreationDate(ctx, type, id) { + try { + const apollo = ctx.resolve("site.apollo"); + if (!apollo) { + ctx.log.error("[Set Timestamp] Apollo client not resolved."); + return null; + } + + const query = type === "clip" ? GET_CLIP : GET_VIDEO; + const variables = type === "clip" ? { slug: id } : { id }; + + const result = await apollo.client.query({ + query, + variables, + }); + + return type === "clip" + ? result?.data?.clip?.createdAt + : result?.data?.video?.createdAt; + } catch (error) { + ctx.log.error(`[Set Timestamp] Error fetching ${type} data:`, error); + return null; + } +} + +async function updateTimestamp(createdAt, ctx) { + const element = await ctx.site.awaitElement(TIMESTAMP_SELECTOR, document.documentElement, 15000); + if (!element) { + ctx.log.error("[Set Timestamp] Timestamp element not found."); + return; + } + + let relative = ""; + if (ctx.settings.get("addon.trubbel.clip-video.timestamps-relative")) { + relative = `(${ctx.i18n.toRelativeTime(createdAt)})`; + } + + const timestamp = ctx.i18n.formatDateTime( + createdAt, + ctx.settings.get("addon.trubbel.clip-video.timestamps-format") + ); + + element.textContent = `${timestamp} ${relative}`; +} + +export default async function setTimestamp(ctx) { + const validRoutes = ["clip-page", "user-clip", "video"]; + if (!validRoutes.includes(ctx.router.current_name)) return; + + const idInfo = await getIdFromURL(ctx); + if (!idInfo) return; + + if (!ctx.settings.get("addon.trubbel.clip-video.timestamps-clip") && idInfo.type === "clip") { + return; + } + if (!ctx.settings.get("addon.trubbel.clip-video.timestamps-video") && idInfo.type === "video") { + return; + } + + const createdAt = await fetchCreationDate(ctx, idInfo.type, idInfo.id); + if (createdAt) { + await updateTimestamp(createdAt, ctx); + } +} \ No newline at end of file diff --git a/src/trubbel/features/clips-videos/set-video-timestamp.js b/src/trubbel/features/clips-videos/set-video-timestamp.js new file mode 100644 index 0000000..3fe7ceb --- /dev/null +++ b/src/trubbel/features/clips-videos/set-video-timestamp.js @@ -0,0 +1,72 @@ +import { VODS_TIMESTAMP } from "../../utils/constants/selectors"; +import GET_VIDEO from "../../utils/graphql/video_info.gql"; + +let lastProcessedVideoId = null; // Tracks the last processed video ID +let processing = false; // Prevents re-entrant calls + +export default async function setVideoTimestamp(ctx) { + if (ctx.router.current?.name === "video") { + if (!ctx.settings.get("addon.trubbel.clip-video.timestamps-video")) return; + + const videoId = location.pathname.split("/videos/")[1]; + if (!videoId) { + ctx.log.error("[Video Timestamp] Unable to get the video ID."); + return; + } + + if (processing) { + ctx.log.info("[Video Timestamp] Skipping as processing is already in progress."); + return; + } + + // Skip if the video ID has already been processed + if (videoId === lastProcessedVideoId) { + ctx.log.info("[Video Timestamp] Skipping as this video ID is already processed."); + return; + } + + processing = true; + lastProcessedVideoId = videoId; + + try { + const metadataElement = await ctx.site.awaitElement(VODS_TIMESTAMP); + if (metadataElement) { + const apollo = ctx.resolve("site.apollo"); + if (!apollo) { + ctx.log.error("[Video Timestamp] Apollo client not resolved."); + return null; + } + + const result = await apollo.client.query({ + query: GET_VIDEO, + variables: { + id: videoId, + }, + }); + + const createdAt = result?.data?.video?.createdAt; + if (createdAt) { + let relative = ""; + if (ctx.settings.get("addon.trubbel.clip-video.timestamps-relative")) { + relative = `(${ctx.i18n.toRelativeTime(createdAt)})`; + } + + const timestamp = ctx.i18n.formatDateTime( + createdAt, + ctx.settings.get("addon.trubbel.clip-video.timestamps-format") + ); + + metadataElement.textContent = `${timestamp} ${relative}`; + } else { + ctx.log.warn("[Video Timestamp] No data found for video."); + } + } else { + ctx.log.warn("[Video Timestamp] Video timestamp element not found."); + } + } catch (error) { + ctx.log.error("[Video Timestamp] Error applying new video timestamp:", error); + } finally { + processing = false; // Ensure processing is reset even on error + } + } +} \ No newline at end of file diff --git a/src/trubbel/features/commands/accountage.js b/src/trubbel/features/commands/accountage.js new file mode 100644 index 0000000..531efcf --- /dev/null +++ b/src/trubbel/features/commands/accountage.js @@ -0,0 +1,37 @@ +import GET_ACCOUNTAGE from "../../utils/graphql/accountage.gql"; +import { formatAccountAge } from "../../utils/format"; + +export default async function getAccountAge(context, inst) { + const userId = inst?.props.currentUserID; + const username = inst?.props.currentUserLogin; + + const twitch_data = await context.twitch_data.getUser(userId); + const displayName = twitch_data.displayName; + const login = twitch_data.login; + const user = displayName.toLowerCase() === login ? displayName : `${displayName} (${login})`; + + const apollo = context.resolve("site.apollo"); + if (!apollo) { + return null; + } + + const result = await apollo.client.query({ + query: GET_ACCOUNTAGE, + variables: { + login: username + } + }); + + const accountAge = result?.data?.user?.createdAt; + if (accountAge) { + inst.addMessage({ + type: context.site.children.chat.chat_types.Notice, + message: `${user} created their account ${formatAccountAge(accountAge)}.` + }); + } else { + inst.addMessage({ + type: context.site.children.chat.chat_types.Notice, + message: `Unable to get accountage for ${user}.` + }); + } +} \ No newline at end of file diff --git a/src/trubbel/features/commands/chatters.js b/src/trubbel/features/commands/chatters.js new file mode 100644 index 0000000..60321d6 --- /dev/null +++ b/src/trubbel/features/commands/chatters.js @@ -0,0 +1,38 @@ +import GET_CHATTERS from "../../utils/graphql/chatters.gql"; + +export default async function getChatters(context, inst) { + const channelLogin = inst?.props.channelLogin; + const twitch_data = await context.twitch_data.getUser(null, channelLogin); + const displayName = twitch_data.displayName; + const login = twitch_data.login; + const streamer = displayName.toLowerCase() === login ? displayName : `${displayName} (${login})`; + + const apollo = context.resolve("site.apollo"); + if (!apollo) { + return null; + } + + const result = await apollo.client.query({ + query: GET_CHATTERS, + variables: { + name: channelLogin + } + }); + + const totalCount = result?.data?.channel?.chatters?.count; + const formattedCount = new Intl.NumberFormat( + document.documentElement.getAttribute("lang") + ).format(totalCount); + + if (totalCount) { + inst.addMessage({ + type: context.site.children.chat.chat_types.Notice, + message: `${streamer} has ${formattedCount} chatters.` + }); + } else { + inst.addMessage({ + type: context.site.children.chat.chat_types.Notice, + message: `Unable to fetch chatter count for ${streamer}.` + }); + } +} \ No newline at end of file diff --git a/src/trubbel/features/commands/followage.js b/src/trubbel/features/commands/followage.js new file mode 100644 index 0000000..c14a6cc --- /dev/null +++ b/src/trubbel/features/commands/followage.js @@ -0,0 +1,35 @@ +import { formatFollowAge } from "../../utils/format"; + +export default async function getFollowAge(context, inst) { + const channelID = inst?.props.channelID; + const channelLogin = inst?.props.channelLogin; + const currentUserID = inst?.props.currentUserID; + + if (channelID === currentUserID) { + inst.addMessage({ + type: context.site.children.chat.chat_types.Notice, + message: "You cannot follow yourself." + }); + return; + } + + const getBroadcaster = await context.twitch_data.getUser(null, channelLogin); + const getUser = await context.twitch_data.getUser(currentUserID); + + const broadcaster = getBroadcaster.displayName.toLowerCase() === getBroadcaster.login ? getBroadcaster.displayName : `${getBroadcaster.displayName} (${getBroadcaster.login})`; + const user = getUser.displayName.toLowerCase() === getUser.login ? getUser.displayName : `${getUser.displayName} (${getUser.login})`; + + const data = await context.twitch_data.getUserFollowed(null, channelLogin); + if (data) { + const followAge = data.followedAt; + inst.addMessage({ + type: context.site.children.chat.chat_types.Notice, + message: `${user} has been following ${broadcaster} for ${formatFollowAge(followAge)}.` + }); + } else { + inst.addMessage({ + type: context.site.children.chat.chat_types.Notice, + message: `${user} is not following ${broadcaster}.` + }); + } +} \ No newline at end of file diff --git a/src/trubbel/features/commands/uptime.js b/src/trubbel/features/commands/uptime.js new file mode 100644 index 0000000..792f3d7 --- /dev/null +++ b/src/trubbel/features/commands/uptime.js @@ -0,0 +1,22 @@ +import { formatLiveDuration } from "../../utils/format"; + +export default async function getStreamUptime(context, inst) { + const channelLogin = inst?.props.channelLogin; + const twitch_data = await context.twitch_data.getUser(null, channelLogin); + const displayName = twitch_data.displayName; + const login = twitch_data.login; + const user = displayName.toLowerCase() === login ? displayName : `${displayName} (${login})`; + + const uptime = context?.site?.children?.chat?.ChatContainer?.first?.props?.streamCreatedAt; + if (uptime) { + inst.addMessage({ + type: context.site.children.chat.chat_types.Notice, + message: `${user} has been live for ${formatLiveDuration(uptime)}.` + }); + } else { + inst.addMessage({ + type: context.site.children.chat.chat_types.Notice, + message: `${user} is offline.` + }); + } +} \ No newline at end of file diff --git a/src/trubbel/features/theme/system-theme.js b/src/trubbel/features/theme/system-theme.js new file mode 100644 index 0000000..63234a9 --- /dev/null +++ b/src/trubbel/features/theme/system-theme.js @@ -0,0 +1,81 @@ +import { showNotification } from "../../utils/create-notification"; + +export function autoChangeTheme(ctx) { + if (!ctx.settings.get("addon.trubbel.ui-tweaks.system-theme")) return; + + const THEME = { + LIGHT: 0, + DARK: 1 + }; + + function findReduxStore() { + const searchReactChildren = (node, predicate) => { + if (!node) return null; + + if (predicate(node)) return node; + + if (node.child) { + let child = node.child; + while (child) { + const result = searchReactChildren(child, predicate); + if (result) return result; + child = child.sibling; + } + } + return null; + }; + + const getReactRoot = (element) => { + const key = Object.keys(element).find(key => + key.startsWith("__reactContainer$") || + key.startsWith("__reactFiber$") + ); + return element[key]; + }; + + const rootElement = document.querySelector("#root"); + const reactRoot = getReactRoot(rootElement); + + const node = searchReactChildren( + reactRoot._internalRoot?.current ?? reactRoot, + (n) => n.pendingProps?.value?.store + ); + + return node?.pendingProps?.value?.store; + } + + function setTheme(store, isDark) { + if (!store) return; + + store.dispatch({ + type: "core.ui.THEME_CHANGED", + theme: isDark ? THEME.DARK : THEME.LIGHT + }); + + showNotification( + isDark ? "🌙" : "☀️", + `[AUTO THEME] Theme set to ${isDark ? "Dark" : "Light"} Mode` + ); + } + + function initialize() { + const store = findReduxStore(); + if (!store) { + setTimeout(initialize, 1000); + return; + } + + const systemPrefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches; + const currentTheme = store.getState().ui.theme; + const twitchIsDark = currentTheme === THEME.DARK; + + if (systemPrefersDark !== twitchIsDark) { + setTheme(store, systemPrefersDark); + } + + window.matchMedia("(prefers-color-scheme: dark)") + .addEventListener("change", (e) => setTheme(store, e.matches)); + } + + setTimeout(initialize, 1000); +} \ No newline at end of file diff --git a/src/trubbel/index.js b/src/trubbel/index.js new file mode 100644 index 0000000..8ae9fb9 --- /dev/null +++ b/src/trubbel/index.js @@ -0,0 +1,50 @@ +import { ChatCommands } from "./settings/chat-commands"; +import { ClipsVideos } from "./settings/clips-videos"; +import { Directory } from "./settings/directory"; +import { DropsRewards } from "./settings/drops-rewards"; +import { Player } from "./settings/player"; +import { RemoveThings } from "./settings/remove-things"; +import { UITweaks } from "./settings/ui-tweaks"; +import { Whispers } from "./settings/whispers"; + +class Trubbel extends Addon { + constructor(...args) { + super(...args); + + this.inject(ChatCommands); + this.inject(ClipsVideos); + this.inject(Directory); + this.inject(DropsRewards); + this.inject(Player); + this.inject(RemoveThings); + this.inject(UITweaks); + this.inject(Whispers); + + this.inject("chat"); + this.inject("chat.badges"); + + this.loadDevBadge(); + } + + async loadDevBadge() { + this.badges.loadBadgeData("addon.trubbel-devbadge", { + addon: "trubbel", + id: "addon.trubbel-devbadge", + base_id: "addon.trubbel-devbadge", + title: "Trubbel\u2019s Utilities\nDeveloper", + slot: 666, + color: "transparent", + image: "https://i.imgur.com/8TRjfOx.png", + urls: { + 1: "https://i.imgur.com/8TRjfOx.png", + 2: "https://i.imgur.com/vu08acF.png", + 4: "https://i.imgur.com/s8TIgUg.png" + }, + click_url: "https://twitch.tv/trubbel" + }); + + this.chat.getUser(39172973, "trubbel").addBadge("addon.trubbel", "addon.trubbel-devbadge"); + } +} + +Trubbel.register(); \ No newline at end of file diff --git a/src/trubbel/logo.png b/src/trubbel/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..2a947488c1364b1175ebb6a41cfef695062fe547 GIT binary patch literal 43543 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�V!cX6MFBCZUyXo;?xKww%CuC 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 Trubbel\u2019s Utilities > Chat >> Custom Commands", + title: "Enable Custom Chat Commands", + component: "setting-check-box" + }, + changed: () => this.handleCustomCommands() + }); + + const commandSettings = { + accountage: "Show how long ago you created your account.", + chatters: "Show the current channels amount of chatters.", + followage: "Show your followage in the current channel.", + uptime: "Show the channels current uptime." + }; + + Object.entries(commandSettings).forEach(([command, description]) => { + this.settings.add(`addon.trubbel.chat.custom-command-${command}`, { + default: false, + requires: ["addon.trubbel.chat.custom-commands"], + process(ctx, val) { + if (!ctx.get("addon.trubbel.chat.custom-commands")) + return false; + return val; + }, + ui: { + sort: 1, + path: "Add-Ons > Trubbel\u2019s Utilities > Chat >> Custom Commands", + title: command.charAt(0).toUpperCase() + command.slice(1), + description: description, + component: "setting-check-box" + }, + changed: () => this.handleCustomCommands() + }); + }); + } + + onEnable() { + this.settings.getChanges("addon.trubbel.chat.custom-commands", () => this.handleCustomCommands()); + this.on("chat:pre-send-message", this.handleMessage); + this.handleCustomCommands(); + } + + onDisable() { + this.removeCommandHandlers(); + this.off("chat:pre-send-message", this.handleMessage); + } + + removeCommandHandlers() { + for (const handler of this.commandHandlers) { + this.off("chat:get-tab-commands", handler); + } + this.commandHandlers.clear(); + } + + getEnabledCommands() { + // Filter commands based on their individual settings + return ffzCommands.filter(command => { + // Extract the command name from the command object + const commandName = command.name.toLowerCase(); + + // Check if this command has a corresponding setting + const settingKey = `addon.trubbel.chat.custom-command-${commandName}`; + + // Only include commands where their specific setting is enabled + return this.settings.get(settingKey); + }); + } + + handleMessage = (e) => { + const msg = e.message; + const inst = e._inst; + + // Get enabled commands + const enabledCommands = this.getEnabledCommands(); + + // Check if the message matches any of our enabled commands + for (const command of enabledCommands) { + const commandRegex = new RegExp(`^\\${command.prefix}${command.name}\\s*`, "i"); + if (commandRegex.test(msg)) { + e.preventDefault(); + + // Handle specific commands + switch (command.name.toLowerCase()) { + case "accountage": + getAccountAge(this, inst); + break; + case "chatters": + getChatters(this, inst); + break; + case "followage": + getFollowAge(this, inst); + break; + case "uptime": + getStreamUptime(this, inst); + break; + default: + inst.addMessage({ + type: this.site.children.chat.chat_types.Notice, + message: `Unknown command: ${command.name}` + }); + } + return; + } + } + } + + handleCustomCommands() { + // Clean up existing handlers + this.removeCommandHandlers(); + + // Only proceed if main custom commands setting is enabled + if (this.settings.get("addon.trubbel.chat.custom-commands")) { + const commandHandler = (e) => { + // Get currently enabled commands and add them to the command list + const enabledCommands = this.getEnabledCommands(); + e.commands.push(...enabledCommands); + }; + + // Register the handler and store it for cleanup + this.on("chat:get-tab-commands", commandHandler); + this.commandHandlers.add(commandHandler); + } + } +} \ No newline at end of file diff --git a/src/trubbel/settings/clips-videos.js b/src/trubbel/settings/clips-videos.js new file mode 100644 index 0000000..960c349 --- /dev/null +++ b/src/trubbel/settings/clips-videos.js @@ -0,0 +1,344 @@ +import setTimestamp from "../features/clips-videos/set-timestamp"; +import applyMostRecentVideoTimestamp from "../features/clips-videos/most-recent-video"; +import GET_VIDEO from "../utils/graphql/video_info.gql"; +import { showNotification } from "../utils/create-notification"; + +export class ClipsVideos extends FrankerFaceZ.utilities.module.Module { + constructor(...args) { + super(...args); + + this.inject("settings"); + this.inject("i18n"); + this.inject("site"); + this.inject("site.fine"); + this.inject("site.player"); + this.inject("site.router"); + + // Clips and Videos - Timestamps - Enable Custom Timestamps for Clips + this.settings.add("addon.trubbel.clip-video.timestamps-clip", { + default: false, + ui: { + sort: 0, + path: "Add-Ons > Trubbel's Utilities > Clips and Videos >> Timestamps", + title: "Enable Custom Timestamps for Clips", + description: "Show the full timestamp when a clip was created.", + component: "setting-check-box" + }, + changed: () => this.getClipOrVideo() + }); + // Clips and Videos - Timestamps - Enable Custom Timestamps for Videos + this.settings.add("addon.trubbel.clip-video.timestamps-video", { + default: false, + ui: { + sort: 1, + path: "Add-Ons > Trubbel's Utilities > Clips and Videos >> Timestamps", + title: "Enable Custom Timestamps for Videos", + description: "Show the full timestamp when a video was created.", + component: "setting-check-box" + }, + changed: () => this.getClipOrVideo() + }); + + // Clips and Videos - Timestamps - Enable Timestamps for Most Recent Videos + this.settings.add("addon.trubbel.clip-video.timestamps-most-recent", { + default: false, + ui: { + sort: 2, + path: "Add-Ons > Trubbel's Utilities > Clips and Videos >> Timestamps", + title: "Enable Timestamps for Most Recent Videos", + description: "Show timestamps on most recent videos when a stream is offline.", + component: "setting-check-box" + }, + changed: () => this.getMostRecentVideo() + }); + + // Clips and Videos - Timestamp - Timestamp Format + this.settings.add("addon.trubbel.clip-video.timestamps-format", { + default: "medium", + ui: { + sort: 3, + path: "Add-Ons > Trubbel's Utilities > Clips and Videos >> Timestamps", + title: "Timestamp Format", + description: "The default combined timestamp format. Custom time formats are formatted using the [Day.js](https://day.js.org/docs/en/display/format) library.", + component: "setting-combo-box", + extra: { + before: true, + mode: "datetime", + component: "format-preview" + }, + data: () => { + const out = [], now = new Date; + for (const [key, fmt] of Object.entries(this.i18n._.formats.datetime)) { + out.push({ + value: key, title: `${this.i18n.formatDateTime(now, key)} (${key})` + }) + } + return out; + } + }, + changed: val => { + this.i18n._.defaultDateTimeFormat = val; + this.emit(":update"); + this.getClipOrVideo(); + } + }); + // Clips and Videos - Timestamp - Enable Relative Timestamp + this.settings.add("addon.trubbel.clip-video.timestamps-relative", { + default: false, + ui: { + sort: 4, + path: "Add-Ons > Trubbel's Utilities > Clips and Videos >> Timestamps", + title: "Enable Relative Timestamp", + description: "Include relative timestamp, such as `(2 days ago)`, `(2 months ago)`, `(2 years ago)` at the end.", + component: "setting-check-box" + }, + changed: () => this.getClipOrVideo() + }); + + // Clips and Videos - VODs - Enable Auto-Skip Muted Segments + this.settings.add("addon.trubbel.vods.skip-segments", { + default: false, + ui: { + sort: 0, + path: "Add-Ons > Trubbel's Utilities > Clips and Videos >> VODs", + title: "Enable Auto-Skip Muted Segments", + description: "Automatically detects and skips muted segments.\n\nOnce you reach the start of a muted segment, this will automatically skip to the end of that muted segment.", + component: "setting-check-box" + }, + changed: () => this.getMutedSegments() + }); + // Clips and Videos - VODs - Enable Auto-Skip Muted Segments + this.settings.add("addon.trubbel.vods.skip-segments-notify", { + default: false, + ui: { + sort: 1, + path: "Add-Ons > Trubbel's Utilities > Clips and Videos >> VODs", + title: "Enable Auto-Skip Notifications", + description: "Show a notification bottom left when a muted segment is skipped.", + component: "setting-check-box" + } + }); + + this.currentVideoId = null; + this.isProcessing = false; + this.pendingFetch = null; + } + + onEnable() { + this.settings.getChanges("addon.trubbel.vods.skip-segments", () => this.getMutedSegments()); + this.router.on(":route", this.checkNavigation, this); + this.checkNavigation(); + + this.settings.getChanges("addon.trubbel.vods.skip-segments", () => this.getMostRecentVideo()); + this.router.on(":route", (route, match) => { + if (route?.name === "clip-page" || route?.name === "user-clip" || route?.name === "video") { + this.getClipOrVideo(); + } + if (route?.name === "mod-view" || route?.name === "user") { + this.getMostRecentVideo(); + } + }); + } + + checkNavigation() { + if ((this.router?.old_name === "video" || this.router?.old_name === "user-home" || this.router?.old_name === "user-videos") && + (this.router.current?.name !== "video" && this.router.current?.name !== "user-home" && this.router.current?.name !== "user-videos")) { + this.cleanupEventListeners(); + } + if (this.router.current?.name === "video" || this.router.current?.name === "user-home" || this.router.current?.name === "user-videos") { + this.getMutedSegments(); + } + } + + getClipOrVideo() { + setTimestamp(this); + } + + getMostRecentVideo() { + const enabled = this.settings.get("addon.trubbel.clip-video.timestamps-most-recent"); + if (enabled) { + applyMostRecentVideoTimestamp(this); + } else { + document.querySelector(".most_recent_video-overlay")?.remove(); + } + } + + cleanupEventListeners() { + const video = document.querySelector("[data-a-target=\"video-player\"] video"); + if (video && video._mutedSegmentsHandler) { + video.removeEventListener("timeupdate", video._mutedSegmentsHandler); + delete video._mutedSegmentsHandler; + } + + this.currentVideoId = null; + this.isProcessing = false; + this.pendingFetch = null; + } + + async waitForVideo() { + try { + const video = await this.site.awaitElement("[data-a-target=\"video-player\"] video", document.documentElement, 15000); + // Pre-check: Look for muted segments in the seekbar + // Instead of fetching information for each video + // Check for muted segments parts in the seekbar before continuing + try { + // Wait for the seekbar to load + const seekbar = await this.site.awaitElement(".seekbar-bar", document.documentElement, 15000); + // Get all the segments + const segments = Array.from(seekbar.querySelectorAll("[data-test-selector=\"seekbar-segment__segment\"]")); + + this.log.info("[Muted Segments] Found seekbar segments:", segments.length); + + // Handle muted segments + const hasMutedSegments = segments.some(segment => { + const style = window.getComputedStyle(segment); + const backgroundColor = style.backgroundColor; + // Look for the muted segment color + return backgroundColor === "rgba(212, 73, 73, 0.5)"; + }); + + // If no muted segments are found in the seekbar, we don't want to continue, to avoid making unnecessary GraphQL requests + if (!hasMutedSegments) { + this.log.info("[Muted Segments] No muted segments found in seekbar, stopping process"); + return false; + } + + this.log.info("[Muted Segments] Found muted segments in seekbar, continuing with processing"); + } catch (error) { + this.log.info("[Muted Segments] Error checking seekbar:", error); + return false; + } + + // If we found muted segments, proceed with getting the videoId + const videoId = this?.site?.children?.player?.PlayerSource?.first?.props?.content?.vodID; + if (videoId) { + return { video, videoId }; + } + + return false; + } catch (error) { + this.log.error("[Muted Segments] Error waiting for video:", error); + return false; + } + } + + async getMutedSegments() { + const enabled = this.settings.get("addon.trubbel.vods.skip-segments"); + if (enabled) { + // Clean up any existing handlers + this.cleanupEventListeners(); + + const result = await this.waitForVideo(); + + // If waitForVideo returns false, stop processing + if (result === false) { + this.log.info("[Muted Segments] Stopping process - no muted segments found or error occurred"); + return; + } + + const { video, videoId } = result; + if (!videoId || !video) return; + + const segments = await this.fetchMutedSegments(videoId); + if (segments) { + const processedSegments = this.processMutedSegments(segments); + await this.setupVideoTimeHandler(video, processedSegments); + } + } else { + this.cleanupEventListeners(); + return; + } + } + + async fetchMutedSegments(videoId) { + if (this.pendingFetch && this.currentVideoId === videoId) { + return this.pendingFetch; + } + + if (videoId === this.currentVideoId || this.isProcessing) return null; + + this.isProcessing = true; + + this.pendingFetch = new Promise(async (resolve) => { + try { + const apollo = this.resolve("site.apollo"); + if (!apollo) { + resolve(null); + return; + } + + const result = await apollo.client.query({ + query: GET_VIDEO, + variables: { id: videoId }, + }); + + const segments = result.data?.video?.muteInfo?.mutedSegmentConnection?.nodes; + if (segments?.length) { + this.log.info("[Muted Segments] Found segments:", segments.length); + this.currentVideoId = videoId; + resolve(segments); + } else { + this.log.info("[Muted Segments] No muted segments found for this video"); + resolve(null); + } + } catch (error) { + this.log.error("[Muted Segments] Error fetching segments:", error); + resolve(null); + } finally { + this.isProcessing = false; + } + }); + + return this.pendingFetch; + } + + processMutedSegments(segments) { + if (!segments) return []; + + const sortedSegments = [...segments].sort((a, b) => a.offset - b.offset); + const combined = []; + let current = { ...sortedSegments[0] }; + + for (let i = 1; i < sortedSegments.length; i++) { + const segment = sortedSegments[i]; + if (segment.offset <= current.offset + current.duration + 1) { + current.duration = Math.max( + current.duration, + segment.offset + segment.duration - current.offset + ); + } else { + combined.push(current); + current = { ...segment }; + } + } + combined.push(current); + this.log.info("[Muted Segments] processMutedSegments() combined:", combined); + return combined; + } + + async setupVideoTimeHandler(video, processedSegments) { + if (!video) return; + + const timeUpdateHandler = () => { + const currentTime = video.currentTime; + for (const segment of processedSegments) { + if (!video.paused && currentTime >= segment.offset && + currentTime < segment.offset + segment.duration) { + video.currentTime = segment.offset + segment.duration; + this.log.info(`[Muted Segments] Skipped segment: ${segment.offset} -> ${segment.offset + segment.duration}`); + if (this.settings.get("addon.trubbel.vods.skip-segments-notify")) { + showNotification("🔇", "Skipped muted segment."); + } + break; + } + } + }; + + if (video._mutedSegmentsHandler) { + video.removeEventListener("timeupdate", video._mutedSegmentsHandler); + } + + video.addEventListener("timeupdate", timeUpdateHandler); + video._mutedSegmentsHandler = timeUpdateHandler; + } +} \ No newline at end of file diff --git a/src/trubbel/settings/directory.js b/src/trubbel/settings/directory.js new file mode 100644 index 0000000..742eeb3 --- /dev/null +++ b/src/trubbel/settings/directory.js @@ -0,0 +1,195 @@ +const { createElement } = FrankerFaceZ.utilities.dom; + +export class Directory extends FrankerFaceZ.utilities.module.Module { + constructor(...args) { + super(...args); + + this.inject("settings"); + this.inject("site.router"); + + // Directory - Thumbnails - Enable Video Previews in Directory + this.settings.add("addon.trubbel.directory.previews", { + default: false, + ui: { + sort: 0, + path: "Add-Ons > Trubbel\u2019s Utilities > Directory >> Thumbnails", + title: "Enable Video Previews in Directory", + description: "Displays a video preview when hovering over channel thumbnails in directories.\n\n**Note:** If you keep seeing \"Click to unmute\", change permission for player.twitch.tv to allow audio/video autoplay.", + component: "setting-check-box" + }, + changed: () => this.handlePreviews() + }); + + // Directory - Thumbnails - Video Previews Quality + this.settings.add("addon.trubbel.directory.preview-quality", { + default: "160p30", + requires: ["addon.trubbel.directory.previews"], + process(ctx, val) { + if (!ctx.get("addon.trubbel.directory.previews")) + return false; + return val; + }, + ui: { + sort: 1, + path: "Add-Ons > Trubbel\u2019s Utilities > Directory >> Thumbnails", + title: "Video Previews Quality", + description: "Change the quality for the previews to be shown in.", + component: "setting-select-box", + data: [ + { title: "Auto", value: "auto" }, + { title: "Source", value: "chunked" }, + { title: "1080p60", value: "1080p60" }, + { title: "720p60", value: "720p60" }, + { title: "720p", value: "720p30" }, + { title: "480p", value: "480p30" }, + { title: "360p", value: "360p30" }, + { title: "160p", value: "160p30" } + ] + }, + changed: () => this.handlePreviews() + }); + + // Directory - Thumbnails - Enable Audio for Previews + this.settings.add("addon.trubbel.directory.preview-audio", { + default: false, + requires: ["addon.trubbel.directory.previews"], + process(ctx, val) { + if (!ctx.get("addon.trubbel.directory.previews")) + return false; + return val; + }, + ui: { + sort: 2, + path: "Add-Ons > Trubbel\u2019s Utilities > Directory >> Thumbnails", + title: "Enable Audio for Previews", + description: "Please keep in mind that this is using your default volume for streams. Some streams may be loud.", + component: "setting-check-box" + }, + changed: () => this.handlePreviews() + }); + + this.handleMouseEnter = this.handleMouseEnter.bind(this); + this.handleMouseLeave = this.handleMouseLeave.bind(this); + } + + onEnable() { + this.settings.getChanges("addon.trubbel.directory.previews", () => this.handlePreviews()); + this.router.on(":route", this.checkNavigation, this); + this.checkNavigation(); + } + + checkNavigation() { + const currentMatch = this.router?.match?.[0]; + const oldMatch = this.router?.old_match?.[0]; + + const isCurrentDirectory = currentMatch?.startsWith("/directory/") ?? false; + const wasDirectory = oldMatch?.startsWith("/directory/") ?? false; + + if (isCurrentDirectory || wasDirectory) { + if (wasDirectory && !isCurrentDirectory) { + this.log.info("[Directory Previews] Leaving directory page, cleaning up event listeners"); + this.cleanupEventListeners(); + } + + if (isCurrentDirectory && this.settings.get("addon.trubbel.directory.previews")) { + this.log.info("[Directory Previews] Entering directory page, setting up event listeners"); + this.setupEventListeners(); + } + } + } + + getVideoPreviewURL(login) { + const quality = this.settings.get("addon.trubbel.directory.preview-quality"); + const muted = !this.settings.get("addon.trubbel.directory.preview-audio"); + + const params = new URLSearchParams({ + channel: login, + enableExtensions: false, + parent: "twitch.tv", + player: "popout", + quality: quality, + muted: muted, + controls: false, + disable_frankerfacez: true + }); + return `https://player.twitch.tv/?${params}`; + } + + createVideoPreview(container, streamer) { + try { + const iframe = createElement("iframe", { + src: this.getVideoPreviewURL(streamer), + style: { + position: "absolute", + top: "0", + left: "0", + width: "100%", + height: "100%", + border: "none", + pointerEvents: "none", + backgroundColor: "transparent" + } + }); + container.appendChild(iframe); + return iframe; + } catch (error) { + this.log.error("[Directory Previews] Error creating preview:", error); + return null; + } + } + + handleMouseEnter(event) { + if (!this.settings.get("addon.trubbel.directory.previews")) return; + // Skip thumbnails that are blurred + if (event.target.closest(".blurred-preview-card-image")) return; + + const link = event.target.closest("[data-a-target=\"preview-card-image-link\"]"); + if (!link) return; + + const href = link.getAttribute("href"); + if (!/^\/(?!videos\/\d+$)[^/]+$/.test(href)) return; + + const streamer = href.substring(1); + const container = link.querySelector(".tw-aspect"); + if (container && !container.querySelector("iframe")) { + const iframe = this.createVideoPreview(container, streamer); + if (iframe) { + container.dataset.previewActive = "true"; + } + } + } + + handleMouseLeave(event) { + const link = event.target.closest("[data-a-target=\"preview-card-image-link\"]"); + if (!link) return; + if (link.contains(event.relatedTarget)) return; + + const container = link.querySelector(".tw-aspect"); + if (container && container.dataset.previewActive === "true") { + const iframe = container.querySelector("iframe"); + if (iframe) { + iframe.remove(); + delete container.dataset.previewActive; + } + } + } + + setupEventListeners() { + document.addEventListener("mouseenter", this.handleMouseEnter, true); + document.addEventListener("mouseleave", this.handleMouseLeave, true); + } + + cleanupEventListeners() { + document.removeEventListener("mouseenter", this.handleMouseEnter, true); + document.removeEventListener("mouseleave", this.handleMouseLeave, true); + } + + handlePreviews() { + const enabled = this.settings.get("addon.trubbel.directory.previews"); + if (enabled) { + this.checkNavigation(); + } else { + this.cleanupEventListeners(); + } + } +} \ No newline at end of file diff --git a/src/trubbel/settings/drops-rewards.js b/src/trubbel/settings/drops-rewards.js new file mode 100644 index 0000000..9eb2532 --- /dev/null +++ b/src/trubbel/settings/drops-rewards.js @@ -0,0 +1,215 @@ +import { STORAGE_KEY_CONFIG } from "../utils/constants/config"; +const { createElement } = FrankerFaceZ.utilities.dom; + +export class DropsRewards extends FrankerFaceZ.utilities.module.Module { + constructor(...args) { + super(...args); + + this.inject("settings"); + this.inject("site"); + this.inject("site.router"); + + this.BUTTON_CLASS = "drops-collapse-button"; + this.hasInitialized = false; + + // Drops & Rewards - Inventory - Enable Collapsible Inventory Drops + this.settings.add("addon.trubbel.drops-rewards.collapsible", { + default: false, + ui: { + sort: 0, + path: "Add-Ons > Trubbel\u2019s Utilities > Drops & Rewards >> Inventory", + title: "Enable Collapsible Inventory Drops", + description: "Allows you to toggle the visibility of Drops, helping you keep the Drops page clean and clutter-free.", + component: "setting-check-box" + }, + changed: () => this.handleCollapsibleDrops() + }); + + this.init = this.init.bind(this); + } + + onEnable() { + this.settings.getChanges("addon.trubbel.drops-rewards.collapsible", () => this.handleCollapsibleDrops()); + this.router.on(":route", this.checkNavigation, this); + this.checkNavigation(); + } + + loadCollapsedState() { + try { + const stored = this.settings.provider.get(STORAGE_KEY_CONFIG); + return stored ? JSON.parse(stored) : {}; + } catch (e) { + this.log.error("[Collapsible Drops] Error loading collapsed state:", e); + return {}; + } + } + + saveCollapsedState(state) { + try { + this.settings.provider.set(STORAGE_KEY_CONFIG, JSON.stringify(state)); + } catch (e) { + this.log.error("[Collapsible Drops] Error saving collapsed state:", e); + } + } + + cleanupStorage() { + const campaigns = document.querySelectorAll(".inventory-campaign-info .tw-link"); + if (campaigns.length === 0) return; + + const state = this.loadCollapsedState(); + const currentCampaigns = new Set(Array.from(campaigns).map(link => link.textContent)); + + let hasChanges = false; + Object.keys(state).forEach(campaignName => { + if (!currentCampaigns.has(campaignName)) { + delete state[campaignName]; + hasChanges = true; + } + }); + + if (hasChanges) { + this.saveCollapsedState(state); + } + } + + createCollapseButton() { + const button = createElement("div", { + class: this.BUTTON_CLASS, + style: ` + background: none; + border: none; + color: var(--color-text-alt-2); + cursor: pointer; + padding: 4px 8px; + margin-left: 8px; + border-radius: 4px; + font-size: 14px; + ` + }); + + button.onmouseover = () => (button.style.backgroundColor = "var(--color-background-button-text-hover)"); + button.onmouseout = () => (button.style.backgroundColor = "transparent"); + return button; + } + + makeCollapsible(infoElement) { + if (infoElement.dataset.collapsibleProcessed) return; + + infoElement.dataset.collapsibleProcessed = "true"; + const nameLink = infoElement.querySelector(".tw-link"); + if (!nameLink) return; + + const campaignName = nameLink.textContent; + const parentElement = infoElement.parentElement; + + parentElement.style.position = "relative"; + const titleHeight = 27; + + const collapseBtn = this.createCollapseButton(); + const collapsedState = this.loadCollapsedState(); + + collapseBtn.style.position = "absolute"; + collapseBtn.style.top = "-1px"; + collapseBtn.style.right = "0px"; + + const isCollapsed = collapsedState[campaignName] || false; + collapseBtn.textContent = isCollapsed ? `▼ Show: ${campaignName}` : "▲ Hide"; + + if (isCollapsed) { + parentElement.style.height = `${titleHeight}px`; + parentElement.style.overflow = "hidden"; + parentElement.style.marginBottom = "10px"; + } + + collapseBtn.onclick = (e) => { + e.stopPropagation(); + const newCollapsed = collapseBtn.textContent.includes("Hide"); + + if (newCollapsed) { + parentElement.style.height = `${titleHeight}px`; + parentElement.style.overflow = "hidden"; + parentElement.style.marginBottom = "10px"; + collapseBtn.textContent = `▼ Show: ${campaignName}`; + } else { + parentElement.style.height = ""; + parentElement.style.overflow = ""; + parentElement.style.marginBottom = ""; + collapseBtn.textContent = "▲ Hide"; + } + + const state = this.loadCollapsedState(); + state[campaignName] = newCollapsed; + this.saveCollapsedState(state); + }; + + parentElement.appendChild(collapseBtn); + } + + async waitForElements() { + let timeoutId; + let animFrameId; + + return new Promise((resolve, reject) => { + timeoutId = setTimeout(() => { + cancelAnimationFrame(animFrameId); + reject(); + }, 10000); + + const checkElements = () => { + const elements = document.querySelectorAll(".inventory-campaign-info"); + if (elements.length > 0) { + clearTimeout(timeoutId); + resolve(elements); + } else { + animFrameId = requestAnimationFrame(checkElements); + } + }; + animFrameId = requestAnimationFrame(checkElements); + }); + } + + checkNavigation() { + const currentLocation = this.router?.location; + const oldLocation = this.router?.old_location; + + const isCurrentInventory = currentLocation === "/drops/inventory" || currentLocation === "/inventory"; + const wasInventory = oldLocation === "/drops/inventory" || oldLocation === "/inventory"; + + if (isCurrentInventory || wasInventory) { + if (wasInventory && !isCurrentInventory) { + this.log.info("[Collapsible Drops] Leaving inventory page"); + this.hasInitialized = false; + } + + if (isCurrentInventory && this.settings.get("addon.trubbel.drops-rewards.collapsible")) { + this.log.info("[Collapsible Drops] Entering inventory page"); + this.init(); + } + } + } + + async init() { + const currentLocation = this.router?.location; + if (currentLocation !== "/drops/inventory" && currentLocation !== "/inventory") return; + if (this.hasInitialized) return; + try { + const infoElements = await this.waitForElements(); + this.cleanupStorage(); + infoElements.forEach(element => this.makeCollapsible(element)); + this.hasInitialized = true; + } catch (error) { + this.log.error("[Collapsible Drops] Failed to find inventory elements"); + this.hasInitialized = false; + } + } + + handleCollapsibleDrops() { + const enabled = this.settings.get("addon.trubbel.drops-rewards.collapsible"); + if (enabled) { + this.init(); + } else { + this.settings.provider.delete(STORAGE_KEY_CONFIG); + this.hasInitialized = false; + } + } +} \ No newline at end of file diff --git a/src/trubbel/settings/player.js b/src/trubbel/settings/player.js new file mode 100644 index 0000000..9a8c4b1 --- /dev/null +++ b/src/trubbel/settings/player.js @@ -0,0 +1,117 @@ +import { ERROR_CODES, ERROR_MESSAGES } from "../utils/constants/player-errors"; +import { showNotification } from "../utils/create-notification"; + +const { on } = FrankerFaceZ.utilities.dom; + +class RateLimiter { + constructor(maxAttempts, timeWindowMs) { + this.maxAttempts = maxAttempts; + this.timeWindowMs = timeWindowMs; + this.attempts = []; + } + + canAttempt() { + const now = Date.now(); + // Remove attempts outside the time window + this.attempts = this.attempts.filter(timestamp => + now - timestamp < this.timeWindowMs + ); + return this.attempts.length < this.maxAttempts; + } + + addAttempt() { + this.attempts.push(Date.now()); + } + + reset() { + this.attempts = []; + } +} + +export class Player extends FrankerFaceZ.utilities.module.Module { + constructor(...args) { + super(...args); + + this.inject("settings"); + this.inject("site"); + this.inject("site.fine"); + this.inject("site.player"); + + // Initialize rate limiter - 3 attempts per 5 minutes + this.rateLimiter = new RateLimiter(3, 5 * 60 * 1000); + + // Player - Player Errors - Enable Auto Reset on Player Errors + this.settings.add("addon.trubbel.player.player-errors", { + default: false, + ui: { + sort: 0, + path: "Add-Ons > Trubbel\u2019s Utilities > Player >> Player Errors", + title: "Enable Auto Reset on Player Errors", + description: "Automatically detects and resets the player when encountering errors like `\u00231000`, `\u00232000`, `\u00233000`, `\u00234000` or `\u00235000`.\n\n**Note:** This will only reset the player three times within five minutes, should you keep getting errors, it's likely something you have to fix yourself.", + component: "setting-check-box" + } + }); + + this.PlayerSource = this.fine.define( + "player-source", + n => n.props && n.props?.playerEvents && n.props?.mediaPlayerInstance + ); + } + + onEnable() { + // "PlayerSource" because we can use "userTriggeredPause" with it + this.PlayerSource.ready((cls, instances) => { + for (const inst of instances) { + const events = inst.props?.playerEvents; + if (events) { + if (!this.settings.get("addon.trubbel.player.player-errors")) return; + const playerErrorHandler = async () => { + + // Always check rate limit + if (!this.rateLimiter.canAttempt()) { + this.log.warn("[Auto Player Reset] Rate limit exceeded. Please wait before trying again."); + showNotification("", "[Auto Player Reset] Too many reset attempts. Please try refreshing the page manually.", 15000); + return; + } + + // make sure the user didnt pause the player + if (!inst?.props?.userTriggeredPause) { + const player = inst?.props?.mediaPlayerInstance; + const playerState = player?.core?.state?.state; + + // continue if player is paused and player state is idle + if (player?.isPaused() && playerState === "Idle") { + const video = player?.core?.mediaSinkManager?.video; + + // make sure the video source attribute is missing + if (!video.getAttribute("src")) { + + // The video download was cancelled. Please try again. (Error #1000) + // There was a network error. Please try again. (Error #2000) + // Your browser encountered an error while decoding the video. (Error #3000) + // This video is unavailable. (Error #5000) + const metadataElement = await this.site.awaitElement(".content-overlay-gate"); + if (metadataElement) { + + const hasError = ERROR_CODES.some(code => metadataElement.textContent.includes(code)); + if (hasError) { + const errorCode = ERROR_CODES.find(code => metadataElement.textContent.includes(code)); + + // Always track the attempt + this.rateLimiter.addAttempt(); + this.site.children.player.resetPlayer(this.site.children.player.current); + + this.log.info(`[Auto Player Reset] ${ERROR_MESSAGES[errorCode]} (Error ${errorCode})`); + showNotification("", `[Auto Player Reset] ${ERROR_MESSAGES[errorCode]} (Error ${errorCode})`, 15000); + } + } + } + } + } + }; + on(events, "PlayerError", playerErrorHandler); + } + } + }); + } +} \ No newline at end of file diff --git a/src/trubbel/settings/remove-things.js b/src/trubbel/settings/remove-things.js new file mode 100644 index 0000000..6beb5d5 --- /dev/null +++ b/src/trubbel/settings/remove-things.js @@ -0,0 +1,248 @@ +const { ManagedStyle } = FrankerFaceZ.utilities.dom; +const { has } = FrankerFaceZ.utilities.object; + +const CLASSES = { + "hide-side-nav-for-you": ".side-nav--expanded [aria-label] :is(.side-nav__title):has(p[class*=\"tw-title\"]:first-child)", + "hide-side-nav-guest-avatar": ".side-nav-card :is(.guest-star-avatar__mini-avatar)", + "hide-side-nav-guest-number": ".side-nav-card [data-a-target=\"side-nav-card-metadata\"] :is(p):nth-child(2)", + "hide-side-nav-hype-train": ".side-nav-card-hype-train-bottom", + "hide-player-live-badge": ".video-player .top-bar .tw-channel-status-text-indicator", + "hide-stream-monthly-recap": "div:first-child article:has(a[href^=\"/recaps/\"])", + "hide-stream-sponsored-content-chat-banner": ".stream-chat div :is(.channel-skins-banner__interactive)", + "hide-stream-sponsored-content-within-player": ".video-player :is(.channel-skins-overlay__background)", + "hide-stream-sponsored-content-below-player": ".channel-info-content div[style]:has(.channel-skins-ribbon__container)", + "hide-vod-muted-segment-popup": ".video-player .muted-segments-alert__scroll-wrapper", +}; + +export class RemoveThings extends FrankerFaceZ.utilities.module.Module { + constructor(...args) { + super(...args); + + this.style = new ManagedStyle; + + this.inject("settings"); + this.inject("site.router"); + + // Remove/Hide Things - Left Navigation - Remove the "For You"-text + this.settings.add("addon.trubbel.remove-things.left-nav-for-you", { + default: false, + ui: { + sort: 0, + path: "Add-Ons > Trubbel\u2019s Utilities > Remove/Hide Things >> Left Navigation", + title: "Remove the \"For You\"-text", + component: "setting-check-box" + }, + changed: val => this.toggleHide("hide-side-nav-for-you", val) + }); + // Remove/Hide Things - Left Navigation - Remove the Guest avatars + this.settings.add("addon.trubbel.remove-things.left-nav-guest-avatar", { + default: false, + ui: { + sort: 0, + path: "Add-Ons > Trubbel\u2019s Utilities > Remove/Hide Things >> Left Navigation", + title: "Remove the Guest avatars", + component: "setting-check-box" + }, + changed: val => this.toggleHide("hide-side-nav-guest-avatar", val) + }); + // Remove/Hide Things - Left Navigation - Remove the Guest +number text + this.settings.add("addon.trubbel.remove-things.left-nav-guest-number", { + default: false, + ui: { + sort: 0, + path: "Add-Ons > Trubbel\u2019s Utilities > Remove/Hide Things >> Left Navigation", + title: "Remove the Guest +number text", + component: "setting-check-box" + }, + changed: val => this.toggleHide("hide-side-nav-guest-number", val) + }); + // Remove/Hide Things - Left Navigation - Remove Hype Train + this.settings.add("addon.trubbel.remove-things.left-nav-hype-train", { + default: false, + ui: { + sort: 0, + path: "Add-Ons > Trubbel\u2019s Utilities > Remove/Hide Things >> Left Navigation", + title: "Remove Hype Train", + component: "setting-check-box" + }, + changed: val => this.toggleHide("hide-side-nav-hype-train", val) + }); + // Remove/Hide Things - Player - Remove the Gradient from Player + this.settings.add("addon.trubbel.remove-things.player-gradient", { + default: false, + ui: { + sort: 0, + path: "Add-Ons > Trubbel\u2019s Utilities > Remove/Hide Things >> Player", + title: "Remove the Gradient from Player", + component: "setting-check-box" + }, + changed: () => this.updateCSS() + }); + // Remove/Hide Things - Player - Remove the "LIVE"-badge from Player + this.settings.add("addon.trubbel.remove-things.player-live", { + default: false, + ui: { + sort: 0, + path: "Add-Ons > Trubbel\u2019s Utilities > Remove/Hide Things >> Player", + title: "Remove the \"LIVE\"-badge from Player", + component: "setting-check-box" + }, + changed: val => this.toggleHide("hide-player-live-badge", val) + }); + // Remove/Hide Things - Stories - Remove Stories + this.settings.add("addon.trubbel.remove-things.stories", { + default: false, + ui: { + sort: 0, + path: "Add-Ons > Trubbel\u2019s Utilities > Remove/Hide Things >> Stories", + title: "Remove Stories", + component: "setting-check-box" + }, + changed: () => this.updateCSS() + }); + // Remove/Hide Things - Stream - Remove Remove About Section and Panels + this.settings.add("addon.trubbel.remove-things.stream-about-panels", { + default: false, + ui: { + sort: 0, + path: "Add-Ons > Trubbel\u2019s Utilities > Remove/Hide Things >> Stream", + title: "Remove About Section and Panels", + component: "setting-check-box" + }, + changed: () => this.updateCSS() + }); + // Remove/Hide Things - Stream - Remove Monthly Recap below Streams + this.settings.add("addon.trubbel.remove-things.stream-monthly-recap", { + default: false, + ui: { + sort: 1, + path: "Add-Ons > Trubbel\u2019s Utilities > Remove/Hide Things >> Stream", + title: "Remove Monthly Recap below Streams", + component: "setting-check-box" + }, + changed: val => this.toggleHide("hide-stream-monthly-recap", val) + }); + // Remove/Hide Things - Stream - Remove Power-ups within the Rewards popup + this.settings.add("addon.trubbel.remove-things.stream-power-ups", { + default: false, + ui: { + sort: 2, + path: "Add-Ons > Trubbel\u2019s Utilities > Remove/Hide Things >> Stream", + title: "Remove Power-ups within the Rewards popup", + component: "setting-check-box" + }, + changed: () => this.updateCSS() + }); + // Remove/Hide Things - Stream - Remove Sponsored content above stream chat + this.settings.add("addon.trubbel.remove-things.stream-sponsored-content-chat-banner", { + default: false, + ui: { + sort: 3, + path: "Add-Ons > Trubbel\u2019s Utilities > Remove/Hide Things >> Stream", + title: "Remove Sponsored content above stream chat", + component: "setting-check-box" + }, + changed: val => this.toggleHide("hide-stream-sponsored-content-chat-banner", val) + }); + // Remove/Hide Things - Stream - Remove Sponsored content within the Player + this.settings.add("addon.trubbel.remove-things.stream-sponsored-content-within-player", { + default: false, + ui: { + sort: 4, + path: "Add-Ons > Trubbel\u2019s Utilities > Remove/Hide Things >> Stream", + title: "Remove Sponsored content within the Player", + component: "setting-check-box" + }, + changed: val => this.toggleHide("hide-stream-sponsored-content-within-player", val) + }); + // Remove/Hide Things - Stream - Remove Sponsored content below the Player + this.settings.add("addon.trubbel.remove-things.stream-sponsored-content-below-player", { + default: false, + ui: { + sort: 5, + path: "Add-Ons > Trubbel\u2019s Utilities > Remove/Hide Things >> Stream", + title: "Remove Sponsored content below the Player", + component: "setting-check-box" + }, + changed: val => this.toggleHide("hide-stream-sponsored-content-below-player", val) + }); + // Remove/Hide Things - VODs - Remove Muted Segments Alerts popups + this.settings.add("addon.trubbel.remove-things.vod-muted-segment-popup", { + default: false, + ui: { + sort: 0, + path: "Add-Ons > Trubbel\u2019s Utilities > Remove/Hide Things >> VODs", + title: "Remove Muted Segments Alerts popups", + component: "setting-check-box" + }, + changed: val => this.toggleHide("hide-vod-muted-segment-popup", val) + }); + } + + onEnable() { + this.toggleHide("hide-side-nav-for-you", this.settings.get("addon.trubbel.remove-things.left-nav-for-you")); + this.toggleHide("hide-side-nav-guest-avatar", this.settings.get("addon.trubbel.remove-things.left-nav-guest-avatar")); + this.toggleHide("hide-side-nav-guest-number", this.settings.get("addon.trubbel.remove-things.left-nav-guest-number")); + this.toggleHide("hide-side-nav-hype-train", this.settings.get("addon.trubbel.remove-things.left-nav-hype-train")); + this.toggleHide("hide-player-live-badge", this.settings.get("addon.trubbel.remove-things.player-live")); + this.toggleHide("hide-stream-monthly-recap", this.settings.get("addon.trubbel.remove-things.stream-monthly-recap")); + this.toggleHide("hide-stream-sponsored-content-chat-banner", this.settings.get("addon.trubbel.remove-things.stream-sponsored-content-chat-banner")); + this.toggleHide("hide-stream-sponsored-content-within-player", this.settings.get("addon.trubbel.remove-things.stream-sponsored-content-within-player")); + this.toggleHide("hide-stream-sponsored-content-below-player", this.settings.get("addon.trubbel.remove-things.stream-sponsored-content-below-player")); + this.toggleHide("hide-vod-muted-segment-popup", this.settings.get("addon.trubbel.remove-things.vod-muted-segment-popup")); + this.updateCSS(); + } + + toggleHide(key, val) { + const k = `hide--${key}`; + if (!val) { + this.style.delete(k); + return; + } + + if (!has(CLASSES, key)) { + throw new Error(`cannot find class for "${key}"`); + } + + this.style.set(k, `${CLASSES[key]} { display: none !important }`); + } + + updateCSS() { + // Remove/Hide Things - Stream - Remove the Gradient from Player + if (this.settings.get("addon.trubbel.remove-things.player-gradient")) { + this.style.set("hide-player-gradient", ".video-player .top-bar, .video-player .player-controls { background: transparent !important; }"); + } else { + this.style.delete("hide-player-gradient"); + } + // Remove/Hide Things - Stream - Remove Remove About Section and Panels + if (this.settings.get("addon.trubbel.remove-things.stream-about-panels")) { + this.style.set("hide-stream-about-panels1", "[id=\"live-channel-about-panel\"], .channel-panels { display: none !important; }"); + this.style.set("hide-stream-about-panels2", ".channel-info-content:not(:has(.timestamp-metadata__bar)) :is(div[style^=\"min-height:\"]) { min-height: 0px !important; }"); + this.style.set("hide-stream-about-panels3", ".channel-info-content:not(:has(.timestamp-metadata__bar)) :is(.tw-tower:has(.tw-placeholder-wrapper)) { display: none !important; }"); + } else { + this.style.delete("hide-stream-about-panels1"); + this.style.delete("hide-stream-about-panels2"); + this.style.delete("hide-stream-about-panels3"); + } + // Remove/Hide Things - Stream - Remove Power-ups within the Rewards popup + if (this.settings.get("addon.trubbel.remove-things.stream-power-ups")) { + this.style.set("hide-stream-power-ups1", ".rewards-list :is([class*=\"bitsRewardListItem--\"]) { display: none !important; }"); + this.style.set("hide-stream-power-ups2", ".rewards-list > div:first-child:has(.tw-title:only-child) { display: none !important; }"); + this.style.set("hide-stream-power-ups3", ".rewards-list > :is(div:has(> div > .tw-title)) { padding: 0rem 0.5rem 1rem !important; }"); + } else { + this.style.delete("hide-stream-power-ups1"); + this.style.delete("hide-stream-power-ups2"); + this.style.delete("hide-stream-power-ups3"); + } + // Remove/Hide Things - Stories - Remove Stories + if (this.settings.get("addon.trubbel.remove-things.stories")) { + this.style.set("hide-stories-left-nav-expanded", "#side-nav [class*=\"storiesLeftNavSection--\"] { display: none !important; }"); + this.style.set("hide-stories-left-nav-collapsed", "#side-nav :is([style]) :has([class*=\"storiesLeftNavSectionCollapsedButton--\"]) { display: none !important; }"); + this.style.set("hide-stories-following-page", "div[class^=\"Layout-sc-\"] > [data-simplebar=\"init\"] > div:nth-child(3) > :has(h2[class*=\"sr-only\"]:is([class^=\"CoreText-sc-\"])) { display: none !important; }"); + } else { + this.style.delete("hide-stories-left-nav-expanded"); + this.style.delete("hide-stories-left-nav-collapsed"); + this.style.delete("hide-stories-following-page"); + } + } +} \ No newline at end of file diff --git a/src/trubbel/settings/ui-tweaks.js b/src/trubbel/settings/ui-tweaks.js new file mode 100644 index 0000000..17f9f5e --- /dev/null +++ b/src/trubbel/settings/ui-tweaks.js @@ -0,0 +1,349 @@ +import { autoChangeTheme } from "../features/theme/system-theme"; +const { ManagedStyle } = FrankerFaceZ.utilities.dom; + +export class UITweaks extends FrankerFaceZ.utilities.module.Module { + constructor(...args) { + super(...args); + + this.style = new ManagedStyle; + + this.inject("settings"); + this.inject("site.router"); + + // UI Tweaks - Chat - Reduce Chat Viewer List Padding + this.settings.add("addon.trubbel.ui-tweaks.chat-viewer-list-padding", { + default: false, + ui: { + sort: 0, + path: "Add-Ons > Trubbel\u2019s Utilities > UI Tweaks >> Chat", + title: "Reduce Chat Viewer List Padding", + description: "Gives the ability to adjust the height of the whisper window drop down.", + component: "setting-check-box" + }, + changed: () => this.updateCSS() + }); + // UI Tweaks - System Theme - Enable System Theme + this.settings.add("addon.trubbel.ui-tweaks.system-theme", { + default: false, + ui: { + sort: 0, + path: "Add-Ons > Trubbel\u2019s Utilities > UI Tweaks >> System Theme", + title: "Enable System Theme", + description: "Automatically sets Twitch theme based on system preferences.", + component: "setting-check-box" + }, + changed: () => this.getCurrentTheme() + }); + // UI Tweaks - Titles - Show full titles for Stream Tooltips + this.settings.add("addon.trubbel.ui-tweaks.full-side-nav-tooltip", { + default: false, + ui: { + sort: 0, + path: "Add-Ons > Trubbel\u2019s Utilities > UI Tweaks >> Titles", + title: "Show full titles for Stream Tooltips", + description: "Show the full title tooltip when hovering over a stream in the left side navigation.", + component: "setting-check-box" + }, + changed: () => this.updateCSS() + }); + // UI Tweaks - Titles - Show full titles in Stream Previews + this.settings.add("addon.trubbel.ui-tweaks.titles-full-stream", { + default: false, + ui: { + sort: 1, + path: "Add-Ons > Trubbel\u2019s Utilities > UI Tweaks >> Titles", + title: "Show full titles in Stream Previews", + component: "setting-check-box" + }, + changed: () => this.updateCSS() + }); + // UI Tweaks - Titles - Show full titles in Clip Previews + this.settings.add("addon.trubbel.ui-tweaks.titles-full-clip", { + default: false, + ui: { + sort: 2, + path: "Add-Ons > Trubbel\u2019s Utilities > UI Tweaks >> Titles", + title: "Show full titles in Clip Previews", + component: "setting-check-box" + }, + changed: () => this.updateCSS() + }); + // UI Tweaks - Titles - Show full titles in VOD Previews + this.settings.add("addon.trubbel.ui-tweaks.titles-full-vod", { + default: false, + ui: { + sort: 3, + path: "Add-Ons > Trubbel\u2019s Utilities > UI Tweaks >> Titles", + title: "Show full titles in VOD Previews", + component: "setting-check-box" + }, + changed: () => this.updateCSS() + }); + // UI Tweaks - Titles - Show full titles for Games in Directory + this.settings.add("addon.trubbel.ui-tweaks.titles-full-game", { + default: false, + ui: { + sort: 4, + path: "Add-Ons > Trubbel\u2019s Utilities > UI Tweaks >> Titles", + title: "Show full titles for Games in Directory", + component: "setting-check-box" + }, + changed: () => this.updateCSS() + }); + // UI Tweaks - Titles - Show full titles for Most Recent Videos + this.settings.add("addon.trubbel.ui-tweaks.titles-most-recent-video", { + default: false, + ui: { + sort: 5, + path: "Add-Ons > Trubbel\u2019s Utilities > UI Tweaks >> Titles", + title: "Show full titles for Most Recent Videos", + component: "setting-check-box" + }, + changed: () => this.updateCSS() + }); + // UI Tweaks - Unban Requests - Hide Mod Actions in Unban Requests + this.settings.add("addon.trubbel.ui-tweaks.unban-requests-hide", { + default: false, + ui: { + sort: 1, + path: "Add-Ons > Trubbel\u2019s Utilities > UI Tweaks >> Unban Requests", + title: "Hide Mod Actions in Unban Requests", + description: "Hide all mod actions taken in the **Unban Requests popout window**.", + component: "setting-check-box" + }, + changed: () => this.updateCSS() + }); + // UI Tweaks - Unban Requests - Remove the line-through text from deleted messages + this.settings.add("addon.trubbel.ui-tweaks.unban-requests-deleted-message", { + default: false, + ui: { + sort: 2, + path: "Add-Ons > Trubbel\u2019s Utilities > UI Tweaks >> Unban Requests", + title: "Remove line-through text in Unban Requests & User Cards", + description: "Remove the line-through text in Unban Requests and within user cards moderated messages.", + component: "setting-check-box" + }, + changed: () => this.updateCSS() + }); + // UI Tweaks - VOD - Show black background behind current and duration time + this.settings.add("addon.trubbel.ui-tweaks.vod-time-background", { + default: false, + ui: { + sort: 0, + path: "Add-Ons > Trubbel\u2019s Utilities > UI Tweaks >> VOD", + title: "Show black background behind current and duration time", + description: "Show a black background behind VODs seekbar, for current time and duration. Making it easier to see the white text.", + component: "setting-check-box" + }, + changed: () => this.updateCSS() + }); + } + + onEnable() { + this.settings.getChanges("addon.trubbel.ui-tweaks.system-theme", () => this.getCurrentTheme()); + this.router.on(":route", () => { + this.getCurrentTheme(); + }); + this.getCurrentTheme(); + + this.updateCSS(); + } + + getCurrentTheme() { + const enabled = this.settings.get("addon.trubbel.ui-tweaks.system-theme"); + if (enabled) { + autoChangeTheme(this); + } + } + + updateCSS() { + // UI Tweaks - Chat - Reduce Chat Viewer List Padding + if (this.settings.get("addon.trubbel.ui-tweaks.chat-viewer-list-padding")) { + this.style.set("viewer-list-padding1", "#community-tab-content > div {padding: 1rem !important;}"); + this.style.set("viewer-list-padding2", ".chatter-list-item {padding: .2rem 0!important;}"); + } else { + this.style.delete("viewer-list-padding1"); + this.style.delete("viewer-list-padding2"); + } + // UI Tweaks - Titles - Show full titles for Stream Tooltips + if (this.settings.get("addon.trubbel.ui-tweaks.full-side-nav-tooltip")) { + this.style.set("show-full-side-nav-tooltip1", ".tw-balloon :has(.online-side-nav-channel-tooltip__body) { max-width: none !important; }"); + this.style.set("show-full-side-nav-tooltip2", ".online-side-nav-channel-tooltip__body :is(p) { display: block !important; -webkit-line-clamp: unset !important; -webkit-box-orient: unset !important; overflow: visible !important; text-overflow: unset !important; }"); + this.style.set("show-full-side-nav-tooltip3", ".tw-balloon :has(.side-nav-guest-star-tooltip__body) { max-width: none !important; }"); + this.style.set("show-full-side-nav-tooltip4", ".side-nav-guest-star-tooltip__body :is(p) { display: block !important; -webkit-line-clamp: unset !important; -webkit-box-orient: unset !important; overflow: visible !important; text-overflow: unset !important; }"); + } else { + this.style.delete("show-full-side-nav-tooltip1"); + this.style.delete("show-full-side-nav-tooltip2"); + this.style.delete("show-full-side-nav-tooltip3"); + this.style.delete("show-full-side-nav-tooltip4"); + } + // UI Tweaks - Titles - Show full titles in Clip Previews + if (this.settings.get("addon.trubbel.ui-tweaks.titles-full-clip")) { + this.style.set("titles-full-clip", "article [href*=\"/clip/\"] :is(h3[title]) {white-space: unset;}"); + } else { + this.style.delete("titles-full-clip"); + } + // UI Tweaks - Titles - Show full titles in Stream Previews + if (this.settings.get("addon.trubbel.ui-tweaks.titles-full-stream")) { + this.style.set("titles-full-stream", "[data-a-target=\"preview-card-channel-link\"] :is(h3[title]) {white-space: unset;}"); + } else { + this.style.delete("titles-full-stream"); + } + // UI Tweaks - Titles - Show full titles in VOD Previews + if (this.settings.get("addon.trubbel.ui-tweaks.titles-full-vod")) { + this.style.set("titles-full-vod", "article [href^=\"/videos/\"] :is(h3[title]) {white-space: unset;}"); + } else { + this.style.delete("titles-full-vod"); + } + // UI Tweaks - Titles - Show full titles for Games in Directory + if (this.settings.get("addon.trubbel.ui-tweaks.titles-full-game")) { + this.style.set("titles-full-game", ".game-card .tw-card-body :is([data-a-target=\"tw-card-title\"]) {white-space: unset;}.game-card .tw-card-body :is(h2[title]) {white-space: unset;}"); + } else { + this.style.delete("titles-full-game"); + } + // UI Tweaks - Titles - Show full titles for Most Recent Videos + if (this.settings.get("addon.trubbel.ui-tweaks.titles-most-recent-video")) { + this.style.set("titles-most-recent-video", ".player-overlay-background p[title] {white-space: unset;}"); + } else { + this.style.delete("titles-most-recent-video"); + } + // UI Tweaks - Unban Requests - Hide Mod Actions in Unban Requests + if (this.settings.get("addon.trubbel.ui-tweaks.unban-requests-hide")) { + // Hides the "Banned By"-text + this.style.set("unban-requests-hide1", ` + .tw-root--theme-dark .mod-view-widget-popout .unban-requests-item-header-tab__banned-by-item button { + color: transparent !important; + background-color: #adadb8 !important; + -webkit-user-select: none !important; + user-select: none !important; + pointer-events: none !important; + padding: 0 90px !important; + } + .tw-root--theme-light .mod-view-widget-popout .unban-requests-item-header-tab__banned-by-item button { + color: transparent !important; + background-color: black !important; + -webkit-user-select: none !important; + user-select: none !important; + pointer-events: none !important; + padding: 0 90px !important; + } + `); + // Removes the "(Deleted by moderator)"-text + this.style.set("unban-requests-hide2", ".mod-view-widget-popout .chat-line__message--deleted span+span {display: none !important;}"); + // Hides the mod actions within the "Chat Logs"-tab + this.style.set("unban-requests-hide3", ` + .tw-root--theme-dark .mod-view-widget-popout .targeted-mod-action .message__timestamp+span { + color: transparent !important; + background-color: #adadb8 !important; + -webkit-user-select: none !important; + user-select: none !important; + padding: 0 90px !important; + } + .tw-root--theme-light .mod-view-widget-popout .targeted-mod-action .message__timestamp+span { + color: transparent !important; + background-color: black !important; + -webkit-user-select: none !important; + user-select: none !important; + padding: 0 90px !important; + } + `); + // Hides the mod actions within the "Mod Comments"-tab + this.style.set("unban-requests-hide4", ` + .tw-root--theme-dark .mod-view-widget-popout .viewer-card-mod-logs-comment-line a span { + color: transparent !important; + background-color: #adadb8 !important; + -webkit-user-select: none !important; + user-select: none !important; + padding: 0 90px !important; + } + .tw-root--theme-light .mod-view-widget-popout .viewer-card-mod-logs-comment-line a span { + color: transparent !important; + background-color: black !important; + -webkit-user-select: none !important; + user-select: none !important; + padding: 0 90px !important; + } + `); + // Making sure the mod link within the "Mod Comments"-tab isn't clickable + this.style.set("unban-requests-hide5", ".mod-view-widget-popout .viewer-card-mod-logs-comment-line a {pointer-events: none !important;}"); + // Removes the "(Deleted by moderator)"-text, if a User Card is opened within Unban Requests + this.style.set("unban-requests-hide6", "#root :has(.mod-view-widget-popout)+.popout-widget__viewer-card-layer .chat-line__message--deleted span+span {display: none !important;}"); + // Hides the mod actions within the "Chat Logs"-tab, if a User Card is opened within Unban Requests + this.style.set("unban-requests-hide7", ` + .tw-root--theme-dark #root :has(.mod-view-widget-popout)+.popout-widget__viewer-card-layer .targeted-mod-action div>span.message__timestamp+span { + color: transparent !important; + background-color: #adadb8 !important; + -webkit-user-select: none !important; + user-select: none !important; + padding: 0 50px !important; + } + .tw-root--theme-light #root :has(.mod-view-widget-popout)+.popout-widget__viewer-card-layer .targeted-mod-action div>span.message__timestamp+span { + color: transparent !important; + background-color: black !important; + -webkit-user-select: none !important; + user-select: none !important; + padding: 0 50px !important; + } + `); + // Hides the moderation action at the bottom of a User Card + this.style.set("unban-requests-hide8", ` + .tw-root--theme-dark #root :has(.mod-view-widget-popout)+.popout-widget__viewer-card-layer .viewer-card-mod-logs>div:not([style]):not(.viewer-card-mod-logs-page) span+span>span { + color: transparent !important; + background-color: #adadb8 !important; + -webkit-user-select: none !important; + user-select: none !important; + padding: 0 50px !important; + } + .tw-root--theme-light #root :has(.mod-view-widget-popout)+.popout-widget__viewer-card-layer .viewer-card-mod-logs>div:not([style]):not(.viewer-card-mod-logs-page) span+span>span { + color: transparent !important; + background-color: black !important; + -webkit-user-select: none !important; + user-select: none !important; + padding: 0 50px !important; + } + `); + // Hides the moderation action within User Card "Mod Comments"-tab + this.style.set("unban-requests-hide9", ` + .tw-root--theme-dark #root :has(.mod-view-widget-popout)+.popout-widget__viewer-card-layer .viewer-card-mod-logs-comment-line a span { + color: transparent !important; + background-color: #adadb8 !important; + -webkit-user-select: none !important; + user-select: none !important; + padding: 0 50px !important; + } + .tw-root--theme-light #root :has(.mod-view-widget-popout)+.popout-widget__viewer-card-layer .viewer-card-mod-logs-comment-line a span { + color: transparent !important; + background-color: black !important; + -webkit-user-select: none !important; + user-select: none !important; + padding: 0 50px !important; + } + `); + // Making sure the mod link within the "Mod Comments"-tab isn't clickable + this.style.set("unban-requests-hide10", "#root :has(.mod-view-widget-popout)+.popout-widget__viewer-card-layer .viewer-card-mod-logs-comment-line a {pointer-events: none !important;}"); + } else { + this.style.delete("unban-requests-hide1"); + this.style.delete("unban-requests-hide2"); + this.style.delete("unban-requests-hide3"); + this.style.delete("unban-requests-hide4"); + this.style.delete("unban-requests-hide5"); + this.style.delete("unban-requests-hide6"); + this.style.delete("unban-requests-hide7"); + this.style.delete("unban-requests-hide8"); + this.style.delete("unban-requests-hide9"); + this.style.delete("unban-requests-hide10"); + } + // UI Tweaks - Unban Requests - Remove the line-through text from deleted messages + if (this.settings.get("addon.trubbel.ui-tweaks.unban-requests-deleted-message")) { + this.style.set("unban-requests-deleted-message", ".vcml-message .chat-line__message--deleted-detailed {text-decoration: none;}"); + } else { + this.style.delete("unban-requests-deleted-message"); + } + // UI Tweaks - VOD - Show black background behind current and duration time + if (this.settings.get("addon.trubbel.ui-tweaks.vod-time-background")) { + this.style.set("vod-time-background", "[data-a-target=\"player-seekbar-current-time\"],[data-a-target=\"player-seekbar-duration\"] {background-color: black;padding: 0px 0.4rem;border-radius: 0.2rem;}"); + } else { + this.style.delete("vod-time-background"); + } + } +} \ No newline at end of file diff --git a/src/trubbel/settings/whispers.js b/src/trubbel/settings/whispers.js new file mode 100644 index 0000000..47727ce --- /dev/null +++ b/src/trubbel/settings/whispers.js @@ -0,0 +1,191 @@ +import { WHISPER_THREADS_SELECTOR } from "../utils/constants/selectors"; +import { WHISPER_HEIGHT_CONFIG } from "../utils/constants/config"; +const { createElement } = FrankerFaceZ.utilities.dom; + +export class Whispers extends FrankerFaceZ.utilities.module.Module { + constructor(...args) { + super(...args); + this.inject("settings"); + + this.observer = null; + this.styleElement = null; + this.boundMouseMove = null; + this.boundMouseUp = null; + this.isResizing = false; + this.currentArea = null; + this.startHeight = 0; + this.startY = 0; + + // Whispers - Resizable - Enable Resizable Drop Down + this.settings.add("addon.trubbel.whispers.resizable", { + default: false, + ui: { + sort: 0, + path: "Add-Ons > Trubbel's Utilities > Whispers >> Resizable", + title: "Enable Resizable Drop Down", + description: "Gives the ability to adjust the height of the whisper window drop down.", + component: "setting-check-box" + }, + changed: () => this.getWhisperWindow() + }); + } + + onEnable() { + this.settings.getChanges("addon.trubbel.whispers.resizable", () => this.getWhisperWindow()); + this.getWhisperWindow(); + } + + cleanup() { + // Remove style element + if (this.styleElement) { + this.styleElement.remove(); + this.styleElement = null; + } + + // Remove event listeners + if (this.boundMouseMove) { + document.removeEventListener("mousemove", this.boundMouseMove); + this.boundMouseMove = null; + } + if (this.boundMouseUp) { + document.removeEventListener("mouseup", this.boundMouseUp); + this.boundMouseUp = null; + } + + // Disconnect observer + if (this.observer) { + this.observer.disconnect(); + this.observer = null; + } + + // Reset instance variables + this.isResizing = false; + this.currentArea = null; + this.startHeight = 0; + this.startY = 0; + } + + initializeResize(scrollArea) { + if (scrollArea.dataset.heightAdjustable) return; + + scrollArea.dataset.heightAdjustable = "true"; + + const storedHeight = this.settings.provider.get(WHISPER_HEIGHT_CONFIG); + if (storedHeight) { + scrollArea.style.height = storedHeight; + } + + scrollArea.appendChild(createElement("div", { + class: "resize-handle" + })); + + scrollArea.addEventListener("mousedown", (e) => { + // Only trigger if clicking near the bottom edge + const rect = scrollArea.getBoundingClientRect(); + if (e.clientY >= rect.bottom - 6) { + this.isResizing = true; + this.currentArea = scrollArea; + this.startHeight = rect.height; + this.startY = e.clientY; + e.preventDefault(); + } + }); + } + + handleMouseMove = (e) => { + if (!this.isResizing || !this.currentArea) return; + + const deltaY = e.clientY - this.startY; + const rect = this.currentArea.getBoundingClientRect(); + + // Calculate the maximum height allowed (20px above the bottom of the browser) + const viewportHeight = window.innerHeight; + const maxHeight = Math.min( + viewportHeight - rect.top - 20, + this.startHeight + deltaY + ); + + const newHeight = Math.max(67, maxHeight); // minimum 67px height + this.currentArea.style.height = `${newHeight}px`; + this.settings.provider.set(WHISPER_HEIGHT_CONFIG, `${newHeight}px`); + } + + handleMouseUp = () => { + this.isResizing = false; + this.currentArea = null; + } + + setupStyles() { + const styleContent = ` + .whispers-threads-box__scrollable-area { + position: relative !important; + } + .whispers-threads-box__scrollable-area::after { + content: ""; + position: absolute; + bottom: 0; + left: 0; + right: 0; + height: 6px; + background: #6441a5; + cursor: ns-resize; + opacity: 0; + transition: opacity 0.2s; + border-top-width: 2px; + border-top-style: dotted; + border-top-color: inherit; + } + .whispers-threads-box__scrollable-area:hover::after { + opacity: 0.5; + } + `; + + this.styleElement = createElement("style", { + textContent: styleContent + }); + document.head.appendChild(this.styleElement); + } + + setupObserver() { + this.observer = new MutationObserver((mutations) => { + for (const mutation of mutations) { + for (const node of mutation.addedNodes) { + if (node.nodeType === Node.ELEMENT_NODE) { + const scrollArea = node.querySelector(WHISPER_THREADS_SELECTOR); + if (scrollArea) { + this.initializeResize(scrollArea); + } + } + } + } + }); + + this.observer.observe(document.body, { + childList: true, + subtree: true + }); + } + + getWhisperWindow() { + const enabled = this.settings.get("addon.trubbel.whispers.resizable"); + if (enabled) { + // Set up styles + this.setupStyles(); + + // Set up event listeners + this.boundMouseMove = this.handleMouseMove.bind(this); + this.boundMouseUp = this.handleMouseUp.bind(this); + document.addEventListener("mousemove", this.boundMouseMove); + document.addEventListener("mouseup", this.boundMouseUp); + + // Set up observer + this.setupObserver(); + } else { + // Clean up existing handlers and observers + this.cleanup(); + if (this.settings.provider.has(WHISPER_HEIGHT_CONFIG)) { + this.settings.provider.delete(WHISPER_HEIGHT_CONFIG); + } + } + } +} \ No newline at end of file diff --git a/src/trubbel/utils/constants/commands.js b/src/trubbel/utils/constants/commands.js new file mode 100644 index 0000000..144d561 --- /dev/null +++ b/src/trubbel/utils/constants/commands.js @@ -0,0 +1,49 @@ +import { PermissionLevels } from "./types"; + +export const ffzCommands = [ + { + prefix: "/", + name: "accountage", + description: "Show how long ago you created your account.", + permissionLevel: PermissionLevels.VIEWER, + ffz_group: "Trubbel\u2019s Utilities", + commandArgs: [] + }, + { + prefix: "/", + name: "chatters", + description: "Show the current channels amount of chatters.", + permissionLevel: PermissionLevels.VIEWER, + ffz_group: "Trubbel\u2019s Utilities", + commandArgs: [] + }, + { + prefix: "/", + name: "followage", + description: "Show your followage in the current channel.", + permissionLevel: PermissionLevels.VIEWER, + ffz_group: "Trubbel\u2019s Utilities", + commandArgs: [] + }, + { + prefix: "/", + name: "uptime", + description: "Show the channels current uptime.", + permissionLevel: PermissionLevels.VIEWER, + ffz_group: "Trubbel\u2019s Utilities", + commandArgs: [] + }, + + + { + prefix: "/", + name: "shrug", + description: "¯\\_(ツ)_/¯", + permissionLevel: PermissionLevels.VIEWER, + ffz_group: "Trubbel\u2019s Utilities", + commandArgs: [ + { name: "message", isRequired: false } + ] + }, + +]; \ No newline at end of file diff --git a/src/trubbel/utils/constants/config.js b/src/trubbel/utils/constants/config.js new file mode 100644 index 0000000..e1886c5 --- /dev/null +++ b/src/trubbel/utils/constants/config.js @@ -0,0 +1,2 @@ +export const STORAGE_KEY_CONFIG = "ffzCollapsibleDrops"; +export const WHISPER_HEIGHT_CONFIG = "ffzWhisperDropDownHeight"; \ No newline at end of file diff --git a/src/trubbel/utils/constants/player-errors.js b/src/trubbel/utils/constants/player-errors.js new file mode 100644 index 0000000..a3c7c12 --- /dev/null +++ b/src/trubbel/utils/constants/player-errors.js @@ -0,0 +1,7 @@ +export const ERROR_CODES = ["#1000", "#2000", "#3000", "#4000", "#5000"]; +export const ERROR_MESSAGES = { + "#1000": "The video download was cancelled. Please try again.", + "#2000": "There was a network error. Please try again.", + "#3000": "Your browser encountered an error while decoding the video.", + "#5000": "This video is unavailable." +}; \ No newline at end of file diff --git a/src/trubbel/utils/constants/selectors.js b/src/trubbel/utils/constants/selectors.js new file mode 100644 index 0000000..85b142a --- /dev/null +++ b/src/trubbel/utils/constants/selectors.js @@ -0,0 +1,4 @@ +export const MOST_RECENT_CARD_SELECTOR = ".offline-recommendations-video-card:has([class*=\"offline-recommendations-video-card-border-\"]) :is(.tw-aspect)"; +export const MOST_RECENT_SELECTOR = ".offline-recommendations-video-card"; +export const TIMESTAMP_SELECTOR = ".timestamp-metadata__bar + p"; +export const WHISPER_THREADS_SELECTOR = ".whispers-threads-box__scrollable-area"; \ No newline at end of file diff --git a/src/trubbel/utils/constants/types.js b/src/trubbel/utils/constants/types.js new file mode 100644 index 0000000..d89cfcf --- /dev/null +++ b/src/trubbel/utils/constants/types.js @@ -0,0 +1,6 @@ +export const PermissionLevels = { + VIEWER: 0, + VIP: 1, + MODERATOR: 2, + BROADCASTER: 3, +}; \ No newline at end of file diff --git a/src/trubbel/utils/create-notification.js b/src/trubbel/utils/create-notification.js new file mode 100644 index 0000000..562580d --- /dev/null +++ b/src/trubbel/utils/create-notification.js @@ -0,0 +1,69 @@ +const { createElement } = FrankerFaceZ.utilities.dom; + +export function showNotification(icon = "", message, timeout = 6000) { + // Create notification element + const notification = createElement("div", { + className: "dynamic-notification", + style: ` + position: fixed; + bottom: 20px; + left: 20px; + background: rgba(0, 0, 0, 0.8); + color: white; + padding: 12px 20px; + border-radius: 8px; + font-size: 16px; + z-index: 9999; + display: flex; + align-items: center; + gap: 8px; + opacity: 0; + transform: translateY(20px); + transition: opacity 0.3s ease, transform 0.3s ease; + ` + }); + + // Stack notifications vertically + const existingNotifications = document.getElementsByClassName("dynamic-notification"); + if (existingNotifications.length > 0) { + const offset = existingNotifications.length * 50; // Adjust spacing as needed + notification.style.bottom = `${20 + offset}px`; + } + + if (icon) { + const iconElement = createElement("div", { + textContent: icon, + style: "font-size: 16px;" + }); + notification.appendChild(iconElement); + } + + const text = createElement("span", { + textContent: message, + style: "font-size: 16px;" + }); + notification.appendChild(text); + document.body.appendChild(notification); + + requestAnimationFrame(() => { + notification.style.opacity = "1"; + notification.style.transform = "translateY(0)"; + }); + + setTimeout(() => { + notification.style.opacity = "0"; + notification.style.transform = "translateY(20px)"; + setTimeout(() => { + notification.remove(); + // Reposition remaining notifications + const remainingNotifications = document.getElementsByClassName("dynamic-notification"); + Array.from(remainingNotifications).forEach((notif, index) => { + notif.style.bottom = `${20 + (index * 50)}px`; + }); + }, 300); + }, timeout); +} +// These can all exist simultaneously +// showNotification("🔄", "Loading data...", 3000); +// showNotification("✅", "Data saved successfully!", 3000); +// showNotification("⚠️", "Warning: Connection slow", 5000); \ No newline at end of file diff --git a/src/trubbel/utils/format.js b/src/trubbel/utils/format.js new file mode 100644 index 0000000..03270b9 --- /dev/null +++ b/src/trubbel/utils/format.js @@ -0,0 +1,75 @@ +export function formatLiveDuration(uptime) { + const now = new Date(); + const startDate = new Date(uptime); + const diffMs = now - startDate; + + const seconds = Math.floor(diffMs / 1e3) % 60; + const minutes = Math.floor(diffMs / (1e3 * 60)) % 60; + const hours = Math.floor(diffMs / (1e3 * 60 * 60)) % 24; + const days = Math.floor(diffMs / (1e3 * 60 * 60 * 24)); + + const parts = []; + if (days > 0) parts.push(`${days} day${days > 1 ? "s" : ""}`); + if (hours > 0) parts.push(`${hours} hour${hours > 1 ? "s" : ""}`); + if (minutes > 0) parts.push(`${minutes} minute${minutes > 1 ? "s" : ""}`); + if (seconds > 0) parts.push(`${seconds} second${seconds > 1 ? "s" : ""}`); + + return `${parts.join(", ").replace(/,([^,]*)$/, " and$1")}`; +} + +export function formatAccountAge(createdAt) { + const now = new Date(); + const accountCreationDate = new Date(createdAt); + const diffMs = now - accountCreationDate; + + const seconds = Math.floor(diffMs / 1e3) % 60; + const minutes = Math.floor(diffMs / (1e3 * 60)) % 60; + const hours = Math.floor(diffMs / (1e3 * 60 * 60)) % 24; + const days = Math.floor(diffMs / (1e3 * 60 * 60 * 24)) % 30; + const months = Math.floor(diffMs / (1e3 * 60 * 60 * 24 * 30)) % 12; + const years = Math.floor(diffMs / (1e3 * 60 * 60 * 24 * 30 * 12)); + + const parts = []; + if (years > 0) parts.push(`${years} year${years > 1 ? "s" : ""}`); + if (months > 0) parts.push(`${months} month${months > 1 ? "s" : ""}`); + if (days > 0) parts.push(`${days} day${days > 1 ? "s" : ""}`); + if (hours > 0) parts.push(`${hours} hour${hours > 1 ? "s" : ""}`); + if (minutes > 0) parts.push(`${minutes} minute${minutes > 1 ? "s" : ""}`); + if (seconds > 0) parts.push(`${seconds} second${seconds > 1 ? "s" : ""}`); + + // If no parts, return "just now" + if (parts.length === 0) return "just created"; + + return parts + .join(", ") + .replace(/,([^,]*)$/, " and$1") + " ago"; +} + +export function formatFollowAge(followedAt) { + const now = new Date(); + const followDate = new Date(followedAt); + const diffMs = now - followDate; + + const seconds = Math.floor(diffMs / 1e3) % 60; + const minutes = Math.floor(diffMs / (1e3 * 60)) % 60; + const hours = Math.floor(diffMs / (1e3 * 60 * 60)) % 24; + const days = Math.floor(diffMs / (1e3 * 60 * 60 * 24)) % 30; + const months = Math.floor(diffMs / (1e3 * 60 * 60 * 24 * 30)) % 12; + const years = Math.floor(diffMs / (1e3 * 60 * 60 * 24 * 365)); + + const parts = []; + if (years > 0) parts.push(`${years} year${years > 1 ? "s" : ""}`); + if (months > 0) parts.push(`${months} month${months > 1 ? "s" : ""}`); + if (days > 0) parts.push(`${days} day${days > 1 ? "s" : ""}`); + if (hours > 0) parts.push(`${hours} hour${hours > 1 ? "s" : ""}`); + if (minutes > 0) parts.push(`${minutes} minute${minutes > 1 ? "s" : ""}`); + if (seconds > 0) parts.push(`${seconds} second${seconds > 1 ? "s" : ""}`); + + // If no parts, return "just followed" + const followDuration = + parts.length === 0 + ? "just followed" + : parts.join(", ").replace(/,([^,]*)$/, " and$1"); + + return followDuration; +} diff --git a/src/trubbel/utils/graphql/accountage.gql b/src/trubbel/utils/graphql/accountage.gql new file mode 100644 index 0000000..536e7e1 --- /dev/null +++ b/src/trubbel/utils/graphql/accountage.gql @@ -0,0 +1,6 @@ +query Trubbel_GetUserAccountAge($login: String!) { + user(login: $login) { + id + createdAt + } +} \ No newline at end of file diff --git a/src/trubbel/utils/graphql/chatters.gql b/src/trubbel/utils/graphql/chatters.gql new file mode 100644 index 0000000..0432da5 --- /dev/null +++ b/src/trubbel/utils/graphql/chatters.gql @@ -0,0 +1,8 @@ +query Trubbel_GetChattersCount($name: String!) { + channel(name: $name) { + id + chatters { + count + } + } +} \ No newline at end of file diff --git a/src/trubbel/utils/graphql/clip_info.gql b/src/trubbel/utils/graphql/clip_info.gql new file mode 100644 index 0000000..6c5424c --- /dev/null +++ b/src/trubbel/utils/graphql/clip_info.gql @@ -0,0 +1,6 @@ +query Trubbel_GetClipDate($slug: ID!) { + clip(slug: $slug) { + id + createdAt + } +} \ No newline at end of file diff --git a/src/trubbel/utils/graphql/video_info.gql b/src/trubbel/utils/graphql/video_info.gql new file mode 100644 index 0000000..487b946 --- /dev/null +++ b/src/trubbel/utils/graphql/video_info.gql @@ -0,0 +1,14 @@ +query Trubbel_GetVideoInfo($id: ID!) { + video(id: $id) { + id + muteInfo { + mutedSegmentConnection { + nodes { + offset + duration + } + } + } + createdAt + } +} \ No newline at end of file