YouTube Enhancer (Stats)

Add a Stats Button.

  1. // ==UserScript==
  2. // @name YouTube Enhancer (Stats)
  3. // @description Add a Stats Button.
  4. // @icon https://raw.githubusercontent.com/exyezed/youtube-enhancer/refs/heads/main/extras/youtube-enhancer.png
  5. // @version 1.8
  6. // @author exyezed
  7. // @namespace https://github.com/exyezed/youtube-enhancer/
  8. // @supportURL https://github.com/exyezed/youtube-enhancer/issues
  9. // @license MIT
  10. // @match https://www.youtube.com/*
  11. // @grant none
  12. // @run-at document-idle
  13. // ==/UserScript==
  14.  
  15. (function() {
  16. 'use strict';
  17.  
  18. const styles = `
  19. .videoStats {
  20. width: 36px;
  21. height: 36px;
  22. border-radius: 50%;
  23. display: flex;
  24. align-items: center;
  25. justify-content: center;
  26. cursor: pointer;
  27. margin-left: 8px;
  28. }
  29. html[dark] .videoStats {
  30. background-color: #ffffff1a;
  31. }
  32. html:not([dark]) .videoStats {
  33. background-color: #0000000d;
  34. }
  35. html[dark] .videoStats:hover {
  36. background-color: #ffffff33;
  37. }
  38. html:not([dark]) .videoStats:hover {
  39. background-color: #00000014;
  40. }
  41. .videoStats svg {
  42. width: 18px;
  43. height: 18px;
  44. }
  45. html[dark] .videoStats svg {
  46. fill: var(--yt-spec-text-primary, #fff);
  47. }
  48. html:not([dark]) .videoStats svg {
  49. fill: var(--yt-spec-text-primary, #030303);
  50. }
  51.  
  52. .shortsStats {
  53. display: flex;
  54. align-items: center;
  55. justify-content: center;
  56. margin-top: 16px;
  57. margin-bottom: 16px;
  58. width: 48px;
  59. height: 48px;
  60. border-radius: 50%;
  61. cursor: pointer;
  62. transition: background-color 0.3s;
  63. }
  64.  
  65. html[dark] .shortsStats {
  66. background-color: rgba(255, 255, 255, 0.1);
  67. }
  68.  
  69. html:not([dark]) .shortsStats {
  70. background-color: rgba(0, 0, 0, 0.05);
  71. }
  72.  
  73. html[dark] .shortsStats:hover {
  74. background-color: rgba(255, 255, 255, 0.2);
  75. }
  76.  
  77. html:not([dark]) .shortsStats:hover {
  78. background-color: rgba(0, 0, 0, 0.1);
  79. }
  80.  
  81. .shortsStats svg {
  82. width: 24px;
  83. height: 24px;
  84. }
  85.  
  86. html[dark] .shortsStats svg {
  87. fill: white;
  88. }
  89.  
  90. html:not([dark]) .shortsStats svg {
  91. fill: black;
  92. }
  93. .stats-menu-container {
  94. position: relative;
  95. display: inline-block;
  96. }
  97.  
  98. .stats-horizontal-menu {
  99. position: absolute;
  100. display: flex;
  101. left: 100%;
  102. top: 0;
  103. height: 100%;
  104. visibility: hidden;
  105. opacity: 0;
  106. transition: visibility 0s, opacity 0.2s linear;
  107. z-index: 100;
  108. }
  109.  
  110. .stats-menu-container:hover .stats-horizontal-menu {
  111. visibility: visible;
  112. opacity: 1;
  113. }
  114.  
  115. .stats-menu-button {
  116. margin-left: 8px;
  117. white-space: nowrap;
  118. }
  119. `;
  120.  
  121. let previousUrl = location.href;
  122. let isChecking = false;
  123. let channelFeatures = {
  124. hasStreams: false,
  125. hasShorts: false
  126. };
  127.  
  128. function addStyles() {
  129. if (!document.querySelector('#youtube-enhancer-styles')) {
  130. const styleElement = document.createElement('style');
  131. styleElement.id = 'youtube-enhancer-styles';
  132. styleElement.textContent = styles;
  133. document.head.appendChild(styleElement);
  134. }
  135. }
  136.  
  137. function getCurrentVideoUrl() {
  138. const url = window.location.href;
  139. const urlParams = new URLSearchParams(window.location.search);
  140. const videoId = urlParams.get('v');
  141. if (videoId) {
  142. return `https://www.youtube.com/watch?v=${videoId}`;
  143. }
  144. const shortsMatch = url.match(/\/shorts\/([^?]+)/);
  145. if (shortsMatch) {
  146. return `https://www.youtube.com/shorts/${shortsMatch[1]}`;
  147. }
  148. return null;
  149. }
  150.  
  151. function getChannelIdentifier() {
  152. const url = window.location.href;
  153. let identifier = '';
  154.  
  155. if (url.includes('/channel/')) {
  156. identifier = url.split('/channel/')[1].split('/')[0];
  157. } else if (url.includes('/@')) {
  158. identifier = url.split('/@')[1].split('/')[0];
  159. }
  160.  
  161. return identifier;
  162. }
  163.  
  164. async function checkChannelTabs(url) {
  165. if (isChecking) return;
  166. isChecking = true;
  167. try {
  168. const response = await fetch(url, {
  169. credentials: 'same-origin'
  170. });
  171. if (!response.ok) {
  172. isChecking = false;
  173. return;
  174. }
  175. const html = await response.text();
  176. const match = html.match(/var ytInitialData = (.+?);<\/script>/);
  177. if (!match || !match[1]) {
  178. isChecking = false;
  179. return;
  180. }
  181. const data = JSON.parse(match[1]);
  182. const tabs = data?.contents?.twoColumnBrowseResultsRenderer?.tabs || [];
  183. let hasStreams = false;
  184. let hasShorts = false;
  185. tabs.forEach(tab => {
  186. const tabUrl = tab?.tabRenderer?.endpoint?.commandMetadata?.webCommandMetadata?.url;
  187. if (tabUrl) {
  188. if (/\/streams$/.test(tabUrl)) hasStreams = true;
  189. if (/\/shorts$/.test(tabUrl)) hasShorts = true;
  190. }
  191. });
  192. channelFeatures = {
  193. hasStreams: hasStreams,
  194. hasShorts: hasShorts
  195. };
  196. const existingMenu = document.querySelector('.stats-menu-container');
  197. if (existingMenu) {
  198. existingMenu.remove();
  199. createStatsMenu();
  200. }
  201. } catch (e) {
  202. } finally {
  203. isChecking = false;
  204. }
  205. }
  206.  
  207. function isChannelPage(url) {
  208. return url.includes('youtube.com/') &&
  209. (url.includes('/channel/') || url.includes('/@')) &&
  210. !url.includes('/video/') &&
  211. !url.includes('/watch');
  212. }
  213.  
  214. function checkUrlChange() {
  215. const currentUrl = location.href;
  216. if (currentUrl !== previousUrl) {
  217. previousUrl = currentUrl;
  218. if (isChannelPage(currentUrl)) {
  219. setTimeout(() => checkChannelTabs(currentUrl), 500);
  220. }
  221. }
  222. }
  223.  
  224. function createStatsIcon(isShorts = false) {
  225. const icon = document.createElement('div');
  226. icon.className = isShorts ? 'shortsStats' : 'videoStats';
  227.  
  228. const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
  229. svg.setAttribute("viewBox", "0 0 512 512");
  230. const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
  231. path.setAttribute("d", "M500 89c13.8-11 16-31.2 5-45s-31.2-16-45-5L319.4 151.5 211.2 70.4c-11.7-8.8-27.8-8.5-39.2 .6L12 199c-13.8 11-16 31.2-5 45s31.2 16 45 5L192.6 136.5l108.2 81.1c11.7 8.8 27.8 8.5 39.2-.6L500 89zM160 256l0 192c0 17.7 14.3 32 32 32s32-14.3 32-32l0-192c0-17.7-14.3-32-32-32s-32 14.3-32 32zM32 352l0 96c0 17.7 14.3 32 32 32s32-14.3 32-32l0-96c0-17.7-14.3-32-32-32s-32 14.3-32 32zm288-64c-17.7 0-32 14.3-32 32l0 128c0 17.7 14.3 32 32 32s32-14.3 32-32l0-128c0-17.7-14.3-32-32-32zm96-32l0 192c0 17.7 14.3 32 32 32s32-14.3 32-32l0-192c0-17.7-14.3-32-32-32s-32 14.3-32 32z");
  232. svg.appendChild(path);
  233. icon.appendChild(svg);
  234.  
  235. icon.addEventListener('click', redirectToStatsAPI);
  236.  
  237. return icon;
  238. }
  239.  
  240. function redirectToStatsAPI() {
  241. const videoUrl = getCurrentVideoUrl();
  242. if (videoUrl) {
  243. const apiUrl = `https://stats.afkarxyz.web.id/?directVideo=${encodeURIComponent(videoUrl)}`;
  244. window.open(apiUrl, '_blank');
  245. }
  246. }
  247.  
  248. function insertIconForRegularVideo() {
  249. const targetSelector = '#owner';
  250. const target = document.querySelector(targetSelector);
  251.  
  252. if (target && !document.querySelector('.videoStats')) {
  253. const statsIcon = createStatsIcon();
  254. target.appendChild(statsIcon);
  255. }
  256. }
  257.  
  258. function insertIconForShorts() {
  259. const shortsContainer = document.querySelector('ytd-reel-video-renderer[is-active] #actions');
  260. if (shortsContainer && !shortsContainer.querySelector('.shortsStats')) {
  261. const iconDiv = createStatsIcon(true);
  262. shortsContainer.insertBefore(iconDiv, shortsContainer.firstChild);
  263. return true;
  264. }
  265. return false;
  266. }
  267.  
  268. function createButton(text, svgPath, viewBox, className, onClick) {
  269. const buttonViewModel = document.createElement('button-view-model');
  270. buttonViewModel.className = `yt-spec-button-view-model ${className}-view-model`;
  271.  
  272. const button = document.createElement('button');
  273. button.className = `yt-spec-button-shape-next yt-spec-button-shape-next--outline yt-spec-button-shape-next--mono yt-spec-button-shape-next--size-m yt-spec-button-shape-next--enable-backdrop-filter-experiment ${className}-button`;
  274. button.setAttribute('aria-disabled', 'false');
  275. button.setAttribute('aria-label', text);
  276. button.style.display = 'flex';
  277. button.style.alignItems = 'center';
  278. button.style.justifyContent = 'center';
  279. button.style.gap = '8px';
  280.  
  281. button.addEventListener('click', onClick);
  282.  
  283. const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
  284. svg.setAttribute("viewBox", viewBox);
  285. svg.style.width = "20px";
  286. svg.style.height = "20px";
  287. svg.style.fill = "currentColor";
  288.  
  289. const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
  290. path.setAttribute("d", svgPath);
  291. svg.appendChild(path);
  292.  
  293. const buttonText = document.createElement('div');
  294. buttonText.className = `yt-spec-button-shape-next__button-text-content ${className}-text`;
  295. buttonText.textContent = text;
  296. buttonText.style.display = 'flex';
  297. buttonText.style.alignItems = 'center';
  298.  
  299. const touchFeedback = document.createElement('yt-touch-feedback-shape');
  300. touchFeedback.style.borderRadius = 'inherit';
  301.  
  302. const touchFeedbackDiv = document.createElement('div');
  303. touchFeedbackDiv.className = 'yt-spec-touch-feedback-shape yt-spec-touch-feedback-shape--touch-response';
  304. touchFeedbackDiv.setAttribute('aria-hidden', 'true');
  305.  
  306. const strokeDiv = document.createElement('div');
  307. strokeDiv.className = 'yt-spec-touch-feedback-shape__stroke';
  308.  
  309. const fillDiv = document.createElement('div');
  310. fillDiv.className = 'yt-spec-touch-feedback-shape__fill';
  311.  
  312. touchFeedbackDiv.appendChild(strokeDiv);
  313. touchFeedbackDiv.appendChild(fillDiv);
  314. touchFeedback.appendChild(touchFeedbackDiv);
  315.  
  316. button.appendChild(svg);
  317. button.appendChild(buttonText);
  318. button.appendChild(touchFeedback);
  319. buttonViewModel.appendChild(button);
  320.  
  321. return buttonViewModel;
  322. }
  323.  
  324. function createStatsMenu() {
  325. if (document.querySelector('.stats-menu-container')) {
  326. return;
  327. }
  328.  
  329. const containerDiv = document.createElement('div');
  330. containerDiv.className = 'yt-flexible-actions-view-model-wiz__action stats-menu-container';
  331.  
  332. const mainButtonViewModel = document.createElement('button-view-model');
  333. mainButtonViewModel.className = 'yt-spec-button-view-model main-stats-view-model';
  334.  
  335. const mainButton = document.createElement('button');
  336. mainButton.className = 'yt-spec-button-shape-next yt-spec-button-shape-next--outline yt-spec-button-shape-next--mono yt-spec-button-shape-next--size-m yt-spec-button-shape-next--enable-backdrop-filter-experiment main-stats-button';
  337. mainButton.setAttribute('aria-disabled', 'false');
  338. mainButton.setAttribute('aria-label', 'Stats');
  339. mainButton.style.display = 'flex';
  340. mainButton.style.alignItems = 'center';
  341. mainButton.style.justifyContent = 'center';
  342. mainButton.style.gap = '8px';
  343.  
  344. const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
  345. svg.setAttribute("viewBox", "0 0 512 512");
  346. svg.style.width = "20px";
  347. svg.style.height = "20px";
  348. svg.style.fill = "currentColor";
  349.  
  350. const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
  351. path.setAttribute("d", "M500 89c13.8-11 16-31.2 5-45s-31.2-16-45-5L319.4 151.5 211.2 70.4c-11.7-8.8-27.8-8.5-39.2 .6L12 199c-13.8 11-16 31.2-5 45s31.2 16 45 5L192.6 136.5l108.2 81.1c11.7 8.8 27.8 8.5 39.2-.6L500 89zM160 256l0 192c0 17.7 14.3 32 32 32s32-14.3 32-32l0-192c0-17.7-14.3-32-32-32s-32 14.3-32 32zM32 352l0 96c0 17.7 14.3 32 32 32s32-14.3 32-32l0-96c0-17.7-14.3-32-32-32s-32 14.3-32 32zm288-64c-17.7 0-32 14.3-32 32l0 128c0 17.7 14.3 32 32 32s32-14.3 32-32l0-128c0-17.7-14.3-32-32-32zm96-32l0 192c0 17.7 14.3 32 32 32s32-14.3 32-32l0-192c0-17.7-14.3-32-32-32s-32 14.3-32 32z");
  352. svg.appendChild(path);
  353.  
  354. const buttonText = document.createElement('div');
  355. buttonText.className = 'yt-spec-button-shape-next__button-text-content main-stats-text';
  356. buttonText.textContent = 'Stats';
  357. buttonText.style.display = 'flex';
  358. buttonText.style.alignItems = 'center';
  359.  
  360. const touchFeedback = document.createElement('yt-touch-feedback-shape');
  361. touchFeedback.style.borderRadius = 'inherit';
  362.  
  363. const touchFeedbackDiv = document.createElement('div');
  364. touchFeedbackDiv.className = 'yt-spec-touch-feedback-shape yt-spec-touch-feedback-shape--touch-response';
  365. touchFeedbackDiv.setAttribute('aria-hidden', 'true');
  366.  
  367. const strokeDiv = document.createElement('div');
  368. strokeDiv.className = 'yt-spec-touch-feedback-shape__stroke';
  369.  
  370. const fillDiv = document.createElement('div');
  371. fillDiv.className = 'yt-spec-touch-feedback-shape__fill';
  372.  
  373. touchFeedbackDiv.appendChild(strokeDiv);
  374. touchFeedbackDiv.appendChild(fillDiv);
  375. touchFeedback.appendChild(touchFeedbackDiv);
  376.  
  377. mainButton.appendChild(svg);
  378. mainButton.appendChild(buttonText);
  379. mainButton.appendChild(touchFeedback);
  380. mainButtonViewModel.appendChild(mainButton);
  381. containerDiv.appendChild(mainButtonViewModel);
  382.  
  383. const horizontalMenu = document.createElement('div');
  384. horizontalMenu.className = 'stats-horizontal-menu';
  385.  
  386. const channelButtonContainer = document.createElement('div');
  387. channelButtonContainer.className = 'stats-menu-button channel-stats-container';
  388. const channelButton = createButton(
  389. 'Channel',
  390. "M64 48c-8.8 0-16 7.2-16 16l0 288c0 8.8 7.2 16 16 16l512 0c8.8 0 16-7.2 16-16l0-288c0-8.8-7.2-16-16-16L64 48zM0 64C0 28.7 28.7 0 64 0L576 0c35.3 0 64 28.7 64 64l0 288c0 35.3-28.7 64-64 64L64 416c-35.3 0-64-28.7-64-64L0 64zM120 464l400 0c13.3 0 24 10.7 24 24s-10.7 24-24 24l-400 0c-13.3 0-24-10.7-24-24s10.7-24 24-24z",
  391. "0 0 640 512",
  392. 'channel-stats',
  393. () => {
  394. const channelId = getChannelIdentifier();
  395. if (channelId) {
  396. const url = `https://stats.afkarxyz.web.id/?directChannel=${channelId}`;
  397. window.open(url, '_blank');
  398. }
  399. }
  400. );
  401. channelButtonContainer.appendChild(channelButton);
  402. horizontalMenu.appendChild(channelButtonContainer);
  403.  
  404. if (channelFeatures.hasStreams) {
  405. const liveButtonContainer = document.createElement('div');
  406. liveButtonContainer.className = 'stats-menu-button live-stats-container';
  407. const liveButton = createButton(
  408. 'Live',
  409. "M99.8 69.4c10.2 8.4 11.6 23.6 3.2 33.8C68.6 144.7 48 197.9 48 256s20.6 111.3 55 152.8c8.4 10.2 7 25.3-3.2 33.8s-25.3 7-33.8-3.2C24.8 389.6 0 325.7 0 256S24.8 122.4 66 72.6c8.4-10.2 23.6-11.6 33.8-3.2zm376.5 0c10.2-8.4 25.3-7 33.8 3.2c41.2 49.8 66 113.8 66 183.4s-24.8 133.6-66 183.4c-8.4 10.2-23.6 11.6-33.8 3.2s-11.6-23.6-3.2-33.8c34.3-41.5 55-94.7 55-152.8s-20.6-111.3-55-152.8c-8.4-10.2-7-25.3 3.2-33.8zM248 256a40 40 0 1 1 80 0 40 40 0 1 1 -80 0zm-61.1-78.5C170 199.2 160 226.4 160 256s10 56.8 26.9 78.5c8.1 10.5 6.3 25.5-4.2 33.7s-25.5 6.3-33.7-4.2c-23.2-29.8-37-67.3-37-108s13.8-78.2 37-108c8.1-10.5 23.2-12.3 33.7-4.2s12.3 23.2 4.2 33.7zM427 148c23.2 29.8 37 67.3 37 108s-13.8 78.2-37 108c-8.1 10.5-23.2 12.3-33.7 4.2s-12.3-23.2-4.2-33.7C406 312.8 416 285.6 416 256s-10-56.8-26.9-78.5c-8.1-10.5-6.3-25.5 4.2-33.7s25.5-6.3 33.7 4.2z",
  410. "0 0 576 512",
  411. 'live-stats',
  412. () => {
  413. const channelId = getChannelIdentifier();
  414. if (channelId) {
  415. const url = `https://stats.afkarxyz.web.id/?directStream=${channelId}`;
  416. window.open(url, '_blank');
  417. }
  418. }
  419. );
  420. liveButtonContainer.appendChild(liveButton);
  421. horizontalMenu.appendChild(liveButtonContainer);
  422. }
  423.  
  424. if (channelFeatures.hasShorts) {
  425. const shortsButtonContainer = document.createElement('div');
  426. shortsButtonContainer.className = 'stats-menu-button shorts-stats-container';
  427. const shortsButton = createButton(
  428. 'Shorts',
  429. "M80 48c-8.8 0-16 7.2-16 16l0 384c0 8.8 7.2 16 16 16l224 0c8.8 0 16-7.2 16-16l0-384c0-8.8-7.2-16-16-16L80 48zM16 64C16 28.7 44.7 0 80 0L304 0c35.3 0 64 28.7 64 64l0 384c0 35.3-28.7 64-64 64L80 512c-35.3 0-64-28.7-64-64L16 64zM160 400l64 0c8.8 0 16 7.2 16 16s-7.2 16-16 16l-64 0c-8.8 0-16-7.2-16-16s7.2-16 16-16z",
  430. "0 0 384 512",
  431. 'shorts-stats',
  432. () => {
  433. const channelId = getChannelIdentifier();
  434. if (channelId) {
  435. const url = `https://stats.afkarxyz.web.id/?directShorts=${channelId}`;
  436. window.open(url, '_blank');
  437. }
  438. }
  439. );
  440. shortsButtonContainer.appendChild(shortsButton);
  441. horizontalMenu.appendChild(shortsButtonContainer);
  442. }
  443.  
  444. containerDiv.appendChild(horizontalMenu);
  445.  
  446. const joinButton = document.querySelector('.yt-flexible-actions-view-model-wiz__action:not(.stats-menu-container)');
  447. if (joinButton) {
  448. joinButton.parentNode.appendChild(containerDiv);
  449. } else {
  450. const buttonContainer = document.querySelector('#subscribe-button + #buttons');
  451. if (buttonContainer) {
  452. buttonContainer.appendChild(containerDiv);
  453. }
  454. }
  455.  
  456. return containerDiv;
  457. }
  458.  
  459. function checkAndAddMenu() {
  460. const joinButton = document.querySelector('.yt-flexible-actions-view-model-wiz__action:not(.stats-menu-container)');
  461. const statsMenu = document.querySelector('.stats-menu-container');
  462.  
  463. if (joinButton && !statsMenu) {
  464. createStatsMenu();
  465. }
  466. }
  467.  
  468. function checkAndInsertIcon() {
  469. const isShorts = window.location.pathname.includes('/shorts/');
  470. if (isShorts) {
  471. const shortsObserver = new MutationObserver((_mutations, observer) => {
  472. if (insertIconForShorts()) {
  473. observer.disconnect();
  474. }
  475. });
  476.  
  477. const shortsContainer = document.querySelector('ytd-shorts');
  478. if (shortsContainer) {
  479. shortsObserver.observe(shortsContainer, {
  480. childList: true,
  481. subtree: true
  482. });
  483. insertIconForShorts();
  484. }
  485. } else if (getCurrentVideoUrl()) {
  486. insertIconForRegularVideo();
  487. }
  488. }
  489.  
  490. function init() {
  491. addStyles();
  492. checkAndInsertIcon();
  493. checkAndAddMenu();
  494. history.pushState = (function(f) {
  495. return function() {
  496. const result = f.apply(this, arguments);
  497. checkUrlChange();
  498. return result;
  499. };
  500. })(history.pushState);
  501.  
  502. history.replaceState = (function(f) {
  503. return function() {
  504. const result = f.apply(this, arguments);
  505. checkUrlChange();
  506. return result;
  507. };
  508. })(history.replaceState);
  509.  
  510. window.addEventListener('popstate', checkUrlChange);
  511. if (isChannelPage(location.href)) {
  512. checkChannelTabs(location.href);
  513. }
  514. }
  515.  
  516. const observer = new MutationObserver((mutations) => {
  517. for (let mutation of mutations) {
  518. if (mutation.type === 'childList') {
  519. checkAndInsertIcon();
  520. checkAndAddMenu();
  521. }
  522. }
  523. });
  524.  
  525. observer.observe(document.body, { childList: true, subtree: true });
  526.  
  527. if (document.readyState === 'loading') {
  528. document.addEventListener('DOMContentLoaded', init);
  529. } else {
  530. init();
  531. }
  532.  
  533. window.addEventListener('yt-navigate-finish', () => {
  534. checkAndInsertIcon();
  535. checkAndAddMenu();
  536. if (isChannelPage(location.href)) {
  537. checkChannelTabs(location.href);
  538. }
  539. });
  540.  
  541. document.addEventListener('yt-action', function(event) {
  542. if (event.detail && event.detail.actionName === 'yt-reload-continuation-items-command') {
  543. checkAndInsertIcon();
  544. checkAndAddMenu();
  545. }
  546. });
  547. })();