Plugin

Dearrow

Makes YouTube embed titles and thumbnails less sensationalist, powered by Dearrow

Media Utility
index.tsx
Download

Source

src/plugins/dearrow/index.tsx
1/*
2 * FiveCord — a Discord client mod
3 * Copyright (c) 2025 FiveCord
4 * SPDX-License-Identifier: GPL-3.0-or-later
5 */
6
7import "./styles.css";
8
9import { definePluginSettings } from "@api/Settings";
10import ErrorBoundary from "@components/ErrorBoundary";
11import { Devs } from "@utils/constants";
12import { Logger } from "@utils/Logger";
13import definePlugin, { OptionType } from "@utils/types";
14import { Tooltip } from "@webpack/common";
15import type { Component } from "react";
16
17interface Props {
18 embed: {
19 rawTitle: string;
20 provider?: {
21 name: string;
22 };
23 thumbnail: {
24 proxyURL: string;
25 };
26 video: {
27 url: string;
28 };
29
30 dearrow: {
31 enabled: boolean;
32 oldTitle?: string;
33 oldThumb?: string;
34 };
35 };
36}
37
38const enum ReplaceElements {
39 ReplaceAllElements,
40 ReplaceTitlesOnly,
41 ReplaceThumbnailsOnly
42}
43
44const embedUrlRe = /https:\/\/www\.youtube\.com\/embed\/([a-zA-Z0-9_-]{11})/;
45
46async function embedDidMount(this: Component<Props>) {
47 try {
48 const { embed } = this.props;
49 const { replaceElements, dearrowByDefault } = settings.store;
50
51 if (!embed || embed.dearrow || embed.provider?.name !== "YouTube" || !embed.video?.url) return;
52
53 const videoId = embedUrlRe.exec(embed.video.url)?.[1];
54 if (!videoId) return;
55
56 const res = await fetch(`https:class="ts-cmt">//sponsor.ajay.app/api/branding?videoID=${videoId}`);
57 if (!res.ok) return;
58
59 const { titles, thumbnails } = await res.json();
60
61 const hasTitle = titles[0]?.votes >= 0;
62 const hasThumb = thumbnails[0]?.votes >= 0 && !thumbnails[0].original;
63
64 if (!hasTitle && !hasThumb) return;
65
66
67 embed.dearrow = {
68 enabled: dearrowByDefault
69 };
70
71 if (hasTitle && replaceElements !== ReplaceElements.ReplaceThumbnailsOnly) {
72 const replacementTitle = titles[0].title.replace(/(^|\s)>(\S)/g, "$1$2");
73
74 embed.dearrow.oldTitle = dearrowByDefault ? embed.rawTitle : replacementTitle;
75 if (dearrowByDefault) embed.rawTitle = replacementTitle;
76 }
77 if (hasThumb && replaceElements !== ReplaceElements.ReplaceTitlesOnly) {
78 const replacementProxyURL = `https:class="ts-cmt">//dearrow-thumb.ajay.app/api/v1/getThumbnail?videoID=${videoId}&time=${thumbnails[0].timestamp}`;
79
80 embed.dearrow.oldThumb = dearrowByDefault ? embed.thumbnail.proxyURL : replacementProxyURL;
81 if (dearrowByDefault) embed.thumbnail.proxyURL = replacementProxyURL;
82 }
83
84 this.forceUpdate();
85 } catch (err) {
86 new Logger("Dearrow").error("Failed to dearrow embed", err);
87 }
88}
89
90function DearrowButton({ component }: { component: Component<Props>; }) {
91 const { embed } = component.props;
92 if (!embed?.dearrow) return null;
93
94 return (
95 <Tooltip text={embed.dearrow.enabled ? "This embed has been dearrowed, click to restore" : "Click to dearrow"}>
96 {({ onMouseEnter, onMouseLeave }) => (
97 <button
98 onMouseEnter={onMouseEnter}
99 onMouseLeave={onMouseLeave}
100 className={"vc-dearrow-toggle-" + (embed.dearrow.enabled ? "on" : "off")}
101 onClick={() => {
102 const { enabled, oldThumb, oldTitle } = embed.dearrow;
103 settings.store.dearrowByDefault = !enabled;
104 embed.dearrow.enabled = !enabled;
105 if (oldTitle) {
106 embed.dearrow.oldTitle = embed.rawTitle;
107 embed.rawTitle = oldTitle;
108 }
109 if (oldThumb) {
110 embed.dearrow.oldThumb = embed.thumbnail.proxyURL;
111 embed.thumbnail.proxyURL = oldThumb;
112 }
113
114 component.forceUpdate();
115 }}
116 >
117 {/* Dearrow Icon, taken from https:class="ts-cmt">//dearrow.ajay.app/logo.svg (and optimised) */}
118 <svg
119 xmlns="http:class="ts-cmt">//www.w3.org/2000/svg"
120 width="24px"
121 height="24px"
122 viewBox="0 0 36 36"
123 aria-label="Toggle Dearrow"
124 className="vc-dearrow-icon"
125 >
126 <path
127 fill="#1213BD"
128 d="M36 18.302c0 4.981-2.46 9.198-5.655 12.462s-7.323 5.152-12.199 5.152s-9.764-1.112-12.959-4.376S0 23.283 0 18.302s2.574-9.38 5.769-12.644S13.271 0 18.146 0s9.394 2.178 12.589 5.442C33.931 8.706 36 13.322 36 18.302z"
129 />
130 <path
131 fill="#88c9f9"
132 d="m 30.394282,18.410186 c 0,3.468849 -1.143025,6.865475 -3.416513,9.137917 -2.273489,2.272442 -5.670115,2.92874 -9.137918,2.92874 -3.467803,0 -6.373515,-1.147212 -8.6470033,-3.419654 -2.2734888,-2.272442 -3.5871299,-5.178154 -3.5871299,-8.647003 0,-3.46885 0.9420533,-6.746149 3.2144954,-9.0196379 2.2724418,-2.2734888 5.5507878,-3.9513905 9.0196378,-3.9513905 3.46885,0 6.492841,1.9322561 8.76633,4.204698 2.273489,2.2724424 3.788101,5.2974804 3.788101,8.7663304 z"
133 />
134 <path
135 fill="#0a62a5"
136 d="m 23.95823,17.818306 c 0,3.153748 -2.644888,5.808102 -5.798635,5.808102 -3.153748,0 -5.599825,-2.654354 -5.599825,-5.808102 0,-3.153747 2.446077,-5.721714 5.599825,-5.721714 3.153747,0 5.798635,2.567967 5.798635,5.721714 z"
137 />
138 </svg>
139
140 </button>
141 )}
142 </Tooltip>
143 );
144}
145
146const settings = definePluginSettings({
147 hideButton: {
148 description: "Hides the Dearrow button from YouTube embeds",
149 type: OptionType.BOOLEAN,
150 default: false,
151 restartNeeded: true
152 },
153 replaceElements: {
154 description: "Choose which elements of the embed will be replaced",
155 type: OptionType.SELECT,
156 restartNeeded: true,
157 options: [
158 { label: "Everything (Titles & Thumbnails)", value: ReplaceElements.ReplaceAllElements, default: true },
159 { label: "Titles", value: ReplaceElements.ReplaceTitlesOnly },
160 { label: "Thumbnails", value: ReplaceElements.ReplaceThumbnailsOnly },
161 ],
162 },
163 dearrowByDefault: {
164 description: "Dearrow videos automatically",
165 type: OptionType.BOOLEAN,
166 default: true,
167 restartNeeded: false
168 }
169});
170
171export default definePlugin({
172 name: "Dearrow",
173 description: "Makes YouTube embed titles and thumbnails less sensationalist, powered by Dearrow",
174 tags: ["Media", "Utility"],
175 authors: [Devs.Ven],
176 settings,
177
178 embedDidMount,
179 renderButton(component: Component<Props>) {
180 return (
181 <ErrorBoundary noop>
182 <DearrowButton component={component} />
183 </ErrorBoundary>
184 );
185 },
186
187 patches: [{
188 find: "this.renderInlineMediaEmbed",
189 replacement: [
190 // patch componentDidMount to replace embed thumbnail and title
191 {
192 match: /render\(\)\{.{0,30}let\{embed:/,
193 replace: "componentDidMount=$self.embedDidMount;$&"
194 },
195
196 // add dearrow button
197 {
198 match: /children:\[(?=null!=\i\?(\i)\.renderSuppressButton)/,
199 replace: "children:[$self.renderButton($1),",
200 predicate: () => !settings.store.hideButton
201 }
202 ]
203 }],
204});
205