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 0000000..2a94748 Binary files /dev/null and b/src/trubbel/logo.png differ diff --git a/src/trubbel/manifest.json b/src/trubbel/manifest.json new file mode 100644 index 0000000..465dcad --- /dev/null +++ b/src/trubbel/manifest.json @@ -0,0 +1,18 @@ +{ + "enabled": true, + "requires": [], + "targets": [ + "main", + "clips" + ], + "name": "Trubbel\u2019s Utilities", + "short_name": "Trubbel", + "description": "Just some random things.", + "author": "Trubbel", + "maintainer": "Trubbel", + "version": "2.0.1", + "search_terms": "trubbel", + "website": "https://twitch.tv/trubbel", + "created": "2025-01-06T23:29:54.496Z", + "updated": "2025-01-06T23:29:54.496Z" +} \ No newline at end of file diff --git a/src/trubbel/settings/chat-commands.js b/src/trubbel/settings/chat-commands.js new file mode 100644 index 0000000..83076e0 --- /dev/null +++ b/src/trubbel/settings/chat-commands.js @@ -0,0 +1,148 @@ +import getAccountAge from "../features/commands/accountage"; +import getChatters from "../features/commands/chatters"; +import getFollowAge from "../features/commands/followage"; +import getStreamUptime from "../features/commands/uptime"; +import { ffzCommands } from "../utils/constants/commands"; + +export class ChatCommands extends FrankerFaceZ.utilities.module.Module { + constructor(...args) { + super(...args); + + this.inject("settings"); + this.inject("chat"); + this.inject("site"); + this.inject("site.chat"); + this.inject("site.apollo"); + this.inject("site.fine"); + this.inject("site.twitch_data"); + + // Store command handler functions for cleanup + this.commandHandlers = new Set(); + + this.settings.add("addon.trubbel.chat.custom-commands", { + default: false, + ui: { + sort: 0, + path: "Add-Ons > 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