YouTube Enhancer (Stats)

Add a Stats Button.

当前为 2025-03-11 提交的版本,查看 最新版本

  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.5
  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. width: 48px;
  58. height: 48px;
  59. border-radius: 50%;
  60. cursor: pointer;
  61. transition: background-color 0.3s;
  62. }
  63.  
  64. html[dark] .shortsStats {
  65. background-color: rgba(255, 255, 255, 0.1);
  66. }
  67.  
  68. html:not([dark]) .shortsStats {
  69. background-color: rgba(0, 0, 0, 0.05);
  70. }
  71.  
  72. html[dark] .shortsStats:hover {
  73. background-color: rgba(255, 255, 255, 0.2);
  74. }
  75.  
  76. html:not([dark]) .shortsStats:hover {
  77. background-color: rgba(0, 0, 0, 0.1);
  78. }
  79.  
  80. .shortsStats svg {
  81. width: 24px;
  82. height: 24px;
  83. }
  84.  
  85. html[dark] .shortsStats svg {
  86. fill: white;
  87. }
  88.  
  89. html:not([dark]) .shortsStats svg {
  90. fill: black;
  91. }
  92. .stats-menu-container {
  93. position: relative;
  94. display: inline-block;
  95. }
  96.  
  97. .stats-horizontal-menu {
  98. position: absolute;
  99. display: flex;
  100. left: 100%;
  101. top: 0;
  102. height: 100%;
  103. visibility: hidden;
  104. opacity: 0;
  105. transition: visibility 0s, opacity 0.2s linear;
  106. z-index: 100;
  107. }
  108.  
  109. .stats-menu-container:hover .stats-horizontal-menu {
  110. visibility: visible;
  111. opacity: 1;
  112. }
  113.  
  114. .stats-menu-button {
  115. margin-left: 8px;
  116. white-space: nowrap;
  117. }
  118. `;
  119.  
  120. let previousUrl = location.href;
  121. let isChecking = false;
  122. let channelFeatures = {
  123. hasStreams: false,
  124. hasShorts: false
  125. };
  126.  
  127. function addStyles() {
  128. if (!document.querySelector('#youtube-enhancer-styles')) {
  129. const styleElement = document.createElement('style');
  130. styleElement.id = 'youtube-enhancer-styles';
  131. styleElement.textContent = styles;
  132. document.head.appendChild(styleElement);
  133. }
  134. }
  135.  
  136. function getVideoId() {
  137. const url = window.location.href;
  138. const urlParams = new URLSearchParams(window.location.search);
  139. const normalVideoId = urlParams.get('v');
  140. const shortsMatch = url.match(/\/shorts\/([^?]+)/);
  141. return normalVideoId || (shortsMatch ? shortsMatch[1] : null);
  142. }
  143.  
  144. function getChannelIdentifier() {
  145. const url = window.location.href;
  146. let identifier = '';
  147.  
  148. if (url.includes('/channel/')) {
  149. identifier = url.split('/channel/')[1].split('/')[0];
  150. } else if (url.includes('/@')) {
  151. identifier = url.split('/@')[1].split('/')[0];
  152. }
  153.  
  154. return identifier;
  155. }
  156.  
  157. async function checkChannelTabs(url) {
  158. if (isChecking) return;
  159. isChecking = true;
  160. try {
  161. const response = await fetch(url, {
  162. credentials: 'same-origin'
  163. });
  164. if (!response.ok) {
  165. isChecking = false;
  166. return;
  167. }
  168. const html = await response.text();
  169. const match = html.match(/var ytInitialData = (.+?);<\/script>/);
  170. if (!match || !match[1]) {
  171. isChecking = false;
  172. return;
  173. }
  174. const data = JSON.parse(match[1]);
  175. const tabs = data?.contents?.twoColumnBrowseResultsRenderer?.tabs || [];
  176. let hasStreams = false;
  177. let hasShorts = false;
  178. tabs.forEach(tab => {
  179. const tabUrl = tab?.tabRenderer?.endpoint?.commandMetadata?.webCommandMetadata?.url;
  180. if (tabUrl) {
  181. if (/\/streams$/.test(tabUrl)) hasStreams = true;
  182. if (/\/shorts$/.test(tabUrl)) hasShorts = true;
  183. }
  184. });
  185. channelFeatures = {
  186. hasStreams: hasStreams,
  187. hasShorts: hasShorts
  188. };
  189. const existingMenu = document.querySelector('.stats-menu-container');
  190. if (existingMenu) {
  191. existingMenu.remove();
  192. createStatsMenu();
  193. }
  194. } catch (e) {
  195. } finally {
  196. isChecking = false;
  197. }
  198. }
  199.  
  200. function isChannelPage(url) {
  201. return url.includes('youtube.com/') &&
  202. (url.includes('/channel/') || url.includes('/@')) &&
  203. !url.includes('/video/') &&
  204. !url.includes('/watch');
  205. }
  206.  
  207. function checkUrlChange() {
  208. const currentUrl = location.href;
  209. if (currentUrl !== previousUrl) {
  210. previousUrl = currentUrl;
  211. if (isChannelPage(currentUrl)) {
  212. setTimeout(() => checkChannelTabs(currentUrl), 500);
  213. }
  214. }
  215. }
  216.  
  217. function createStatsIcon(isShorts = false) {
  218. const icon = document.createElement('div');
  219. icon.className = isShorts ? 'shortsStats' : 'videoStats';
  220.  
  221. const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
  222. svg.setAttribute("viewBox", "0 0 512 512");
  223. const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
  224. 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");
  225. svg.appendChild(path);
  226. icon.appendChild(svg);
  227.  
  228. icon.addEventListener('click', redirectToStatsAPI);
  229.  
  230. return icon;
  231. }
  232.  
  233. function redirectToStatsAPI() {
  234. const videoId = getVideoId();
  235. if (videoId) {
  236. const apiUrl = `https://afkarxyzstats.vercel.app/?directVideo=${videoId}`;
  237. window.open(apiUrl, '_blank');
  238. }
  239. }
  240.  
  241. function insertIconForRegularVideo() {
  242. const targetSelector = '#owner';
  243. const target = document.querySelector(targetSelector);
  244.  
  245. if (target && !document.querySelector('.videoStats')) {
  246. const statsIcon = createStatsIcon();
  247. target.appendChild(statsIcon);
  248. }
  249. }
  250.  
  251. function insertIconForShorts() {
  252. const shortsContainer = document.querySelector('ytd-reel-video-renderer[is-active] #actions');
  253. if (shortsContainer && !shortsContainer.querySelector('.shortsStats')) {
  254. const iconDiv = createStatsIcon(true);
  255. shortsContainer.insertBefore(iconDiv, shortsContainer.firstChild);
  256. return true;
  257. }
  258. return false;
  259. }
  260.  
  261. function createButton(text, svgPath, viewBox, className, onClick) {
  262. const buttonViewModel = document.createElement('button-view-model');
  263. buttonViewModel.className = `yt-spec-button-view-model ${className}-view-model`;
  264.  
  265. const button = document.createElement('button');
  266. 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`;
  267. button.setAttribute('aria-disabled', 'false');
  268. button.setAttribute('aria-label', text);
  269. button.style.display = 'flex';
  270. button.style.alignItems = 'center';
  271. button.style.justifyContent = 'center';
  272. button.style.gap = '8px';
  273.  
  274. button.addEventListener('click', onClick);
  275.  
  276. const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
  277. svg.setAttribute("viewBox", viewBox);
  278. svg.style.width = "20px";
  279. svg.style.height = "20px";
  280. svg.style.fill = "currentColor";
  281.  
  282. const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
  283. path.setAttribute("d", svgPath);
  284. svg.appendChild(path);
  285.  
  286. const buttonText = document.createElement('div');
  287. buttonText.className = `yt-spec-button-shape-next__button-text-content ${className}-text`;
  288. buttonText.textContent = text;
  289. buttonText.style.display = 'flex';
  290. buttonText.style.alignItems = 'center';
  291.  
  292. const touchFeedback = document.createElement('yt-touch-feedback-shape');
  293. touchFeedback.style.borderRadius = 'inherit';
  294.  
  295. const touchFeedbackDiv = document.createElement('div');
  296. touchFeedbackDiv.className = 'yt-spec-touch-feedback-shape yt-spec-touch-feedback-shape--touch-response';
  297. touchFeedbackDiv.setAttribute('aria-hidden', 'true');
  298.  
  299. const strokeDiv = document.createElement('div');
  300. strokeDiv.className = 'yt-spec-touch-feedback-shape__stroke';
  301.  
  302. const fillDiv = document.createElement('div');
  303. fillDiv.className = 'yt-spec-touch-feedback-shape__fill';
  304.  
  305. touchFeedbackDiv.appendChild(strokeDiv);
  306. touchFeedbackDiv.appendChild(fillDiv);
  307. touchFeedback.appendChild(touchFeedbackDiv);
  308.  
  309. button.appendChild(svg);
  310. button.appendChild(buttonText);
  311. button.appendChild(touchFeedback);
  312. buttonViewModel.appendChild(button);
  313.  
  314. return buttonViewModel;
  315. }
  316.  
  317. function createStatsMenu() {
  318. if (document.querySelector('.stats-menu-container')) {
  319. return;
  320. }
  321.  
  322. const containerDiv = document.createElement('div');
  323. containerDiv.className = 'yt-flexible-actions-view-model-wiz__action stats-menu-container';
  324.  
  325. const mainButtonViewModel = document.createElement('button-view-model');
  326. mainButtonViewModel.className = 'yt-spec-button-view-model main-stats-view-model';
  327.  
  328. const mainButton = document.createElement('button');
  329. 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';
  330. mainButton.setAttribute('aria-disabled', 'false');
  331. mainButton.setAttribute('aria-label', 'Stats');
  332. mainButton.style.display = 'flex';
  333. mainButton.style.alignItems = 'center';
  334. mainButton.style.justifyContent = 'center';
  335. mainButton.style.gap = '8px';
  336.  
  337. const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
  338. svg.setAttribute("viewBox", "0 0 512 512");
  339. svg.style.width = "20px";
  340. svg.style.height = "20px";
  341. svg.style.fill = "currentColor";
  342.  
  343. const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
  344. 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");
  345. svg.appendChild(path);
  346.  
  347. const buttonText = document.createElement('div');
  348. buttonText.className = 'yt-spec-button-shape-next__button-text-content main-stats-text';
  349. buttonText.textContent = 'Stats';
  350. buttonText.style.display = 'flex';
  351. buttonText.style.alignItems = 'center';
  352.  
  353. const touchFeedback = document.createElement('yt-touch-feedback-shape');
  354. touchFeedback.style.borderRadius = 'inherit';
  355.  
  356. const touchFeedbackDiv = document.createElement('div');
  357. touchFeedbackDiv.className = 'yt-spec-touch-feedback-shape yt-spec-touch-feedback-shape--touch-response';
  358. touchFeedbackDiv.setAttribute('aria-hidden', 'true');
  359.  
  360. const strokeDiv = document.createElement('div');
  361. strokeDiv.className = 'yt-spec-touch-feedback-shape__stroke';
  362.  
  363. const fillDiv = document.createElement('div');
  364. fillDiv.className = 'yt-spec-touch-feedback-shape__fill';
  365.  
  366. touchFeedbackDiv.appendChild(strokeDiv);
  367. touchFeedbackDiv.appendChild(fillDiv);
  368. touchFeedback.appendChild(touchFeedbackDiv);
  369.  
  370. mainButton.appendChild(svg);
  371. mainButton.appendChild(buttonText);
  372. mainButton.appendChild(touchFeedback);
  373. mainButtonViewModel.appendChild(mainButton);
  374. containerDiv.appendChild(mainButtonViewModel);
  375.  
  376. const horizontalMenu = document.createElement('div');
  377. horizontalMenu.className = 'stats-horizontal-menu';
  378.  
  379. const channelButtonContainer = document.createElement('div');
  380. channelButtonContainer.className = 'stats-menu-button channel-stats-container';
  381. const channelButton = createButton(
  382. 'Channel',
  383. "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",
  384. "0 0 640 512",
  385. 'channel-stats',
  386. () => {
  387. const channelId = getChannelIdentifier();
  388. if (channelId) {
  389. const url = `https://afkarxyzstats.vercel.app/?directChannel=${channelId}`;
  390. window.open(url, '_blank');
  391. }
  392. }
  393. );
  394. channelButtonContainer.appendChild(channelButton);
  395. horizontalMenu.appendChild(channelButtonContainer);
  396.  
  397. if (channelFeatures.hasStreams) {
  398. const liveButtonContainer = document.createElement('div');
  399. liveButtonContainer.className = 'stats-menu-button live-stats-container';
  400. const liveButton = createButton(
  401. 'Live',
  402. "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",
  403. "0 0 576 512",
  404. 'live-stats',
  405. () => {
  406. const channelId = getChannelIdentifier();
  407. if (channelId) {
  408. const url = `https://afkarxyzstats.vercel.app/?directStream=${channelId}`;
  409. window.open(url, '_blank');
  410. }
  411. }
  412. );
  413. liveButtonContainer.appendChild(liveButton);
  414. horizontalMenu.appendChild(liveButtonContainer);
  415. }
  416.  
  417. if (channelFeatures.hasShorts) {
  418. const shortsButtonContainer = document.createElement('div');
  419. shortsButtonContainer.className = 'stats-menu-button shorts-stats-container';
  420. const shortsButton = createButton(
  421. 'Shorts',
  422. "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",
  423. "0 0 384 512",
  424. 'shorts-stats',
  425. () => {
  426. const channelId = getChannelIdentifier();
  427. if (channelId) {
  428. const url = `https://afkarxyzstats.vercel.app/?directShorts=${channelId}`;
  429. window.open(url, '_blank');
  430. }
  431. }
  432. );
  433. shortsButtonContainer.appendChild(shortsButton);
  434. horizontalMenu.appendChild(shortsButtonContainer);
  435. }
  436.  
  437. containerDiv.appendChild(horizontalMenu);
  438.  
  439. const joinButton = document.querySelector('.yt-flexible-actions-view-model-wiz__action:not(.stats-menu-container)');
  440. if (joinButton) {
  441. joinButton.parentNode.appendChild(containerDiv);
  442. } else {
  443. const buttonContainer = document.querySelector('#subscribe-button + #buttons');
  444. if (buttonContainer) {
  445. buttonContainer.appendChild(containerDiv);
  446. }
  447. }
  448.  
  449. return containerDiv;
  450. }
  451.  
  452. function checkAndAddMenu() {
  453. const joinButton = document.querySelector('.yt-flexible-actions-view-model-wiz__action:not(.stats-menu-container)');
  454. const statsMenu = document.querySelector('.stats-menu-container');
  455.  
  456. if (joinButton && !statsMenu) {
  457. createStatsMenu();
  458. }
  459. }
  460.  
  461. function checkAndInsertIcon() {
  462. const isShorts = window.location.pathname.includes('/shorts/');
  463. if (isShorts) {
  464. const shortsObserver = new MutationObserver((_mutations, observer) => {
  465. if (insertIconForShorts()) {
  466. observer.disconnect();
  467. }
  468. });
  469.  
  470. const shortsContainer = document.querySelector('ytd-shorts');
  471. if (shortsContainer) {
  472. shortsObserver.observe(shortsContainer, {
  473. childList: true,
  474. subtree: true
  475. });
  476. insertIconForShorts();
  477. }
  478. } else if (getVideoId()) {
  479. insertIconForRegularVideo();
  480. }
  481. }
  482.  
  483. function init() {
  484. addStyles();
  485. checkAndInsertIcon();
  486. checkAndAddMenu();
  487. history.pushState = (function(f) {
  488. return function() {
  489. const result = f.apply(this, arguments);
  490. checkUrlChange();
  491. return result;
  492. };
  493. })(history.pushState);
  494.  
  495. history.replaceState = (function(f) {
  496. return function() {
  497. const result = f.apply(this, arguments);
  498. checkUrlChange();
  499. return result;
  500. };
  501. })(history.replaceState);
  502.  
  503. window.addEventListener('popstate', checkUrlChange);
  504. if (isChannelPage(location.href)) {
  505. checkChannelTabs(location.href);
  506. }
  507. }
  508.  
  509. const observer = new MutationObserver((mutations) => {
  510. for (let mutation of mutations) {
  511. if (mutation.type === 'childList') {
  512. checkAndInsertIcon();
  513. checkAndAddMenu();
  514. }
  515. }
  516. });
  517.  
  518. observer.observe(document.body, { childList: true, subtree: true });
  519.  
  520. if (document.readyState === 'loading') {
  521. document.addEventListener('DOMContentLoaded', init);
  522. } else {
  523. init();
  524. }
  525.  
  526. window.addEventListener('yt-navigate-finish', () => {
  527. checkAndInsertIcon();
  528. checkAndAddMenu();
  529. if (isChannelPage(location.href)) {
  530. checkChannelTabs(location.href);
  531. }
  532. });
  533.  
  534. document.addEventListener('yt-action', function(event) {
  535. if (event.detail && event.detail.actionName === 'yt-reload-continuation-items-command') {
  536. checkAndInsertIcon();
  537. checkAndAddMenu();
  538. }
  539. });
  540. })();