X Spaces +

Addon for X Spaces with custom emojis, better transcript, and speaker queuing.

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

  1. // ==UserScript==
  2. // @name X Spaces +
  3. // @namespace Violentmonkey Scripts
  4. // @version 1.75-reverted
  5. // @description Addon for X Spaces with custom emojis, better transcript, and speaker queuing.
  6. // @author x.com/blankspeaker and x.com/PrestonHenshawX
  7. // @match https://twitter.com/*
  8. // @match https://x.com/*
  9. // @run-at document-start
  10. // @grant none
  11. // ==/UserScript==
  12.  
  13. (function () {
  14. 'use strict';
  15.  
  16. const OrigWebSocket = window.WebSocket;
  17. let myUserId = null;
  18. let captionsData = [];
  19. let emojiReactions = [];
  20. let currentSpaceId = null;
  21. let lastSpaceId = null;
  22. let handRaiseDurations = [];
  23. const activeHandRaises = new Map();
  24.  
  25. let selectedCustomEmoji = null;
  26.  
  27. const customEmojis = [
  28. '😂', '😲', '😢', '✌️', '💯',
  29. '👏', '✊', '👍', '👎', '👋',
  30. '😍', '😃', '😠', '🤔', '😷',
  31. '🔥', '🎯', '✨', '🥇', '✋',
  32. '🙌', '🙏', '🎶', '🎙', '🙉',
  33. '🪐', '🎨', '🎮', '🏛️', '💸',
  34. '🌲', '🐞', '❤️', '🧡', '💛',
  35. '💚', '💙', '💜', '🖤', '🤎',
  36. '💄', '🏠', '💡', '💢', '💻',
  37. '🖥️', '📺', '🎚️', '🎛️', '📡',
  38. '🔋', '🗒️', '📰', '📌', '💠',
  39. ];
  40.  
  41. const originalEmojis = ['😂', '😲', '😢', '💜', '💯', '👏', '✊', '👍', '👎', '👋'];
  42. const emojiMap = new Map();
  43. customEmojis.forEach((emoji, index) => {
  44. const originalEmoji = originalEmojis[index % originalEmojis.length];
  45. emojiMap.set(emoji, originalEmoji);
  46. });
  47.  
  48. function debounce(func, wait) {
  49. let timeout;
  50. return function (...args) {
  51. clearTimeout(timeout);
  52. timeout = setTimeout(() => func(...args), wait);
  53. };
  54. }
  55.  
  56. function getSpaceIdFromUrl() {
  57. const urlMatch = window.location.pathname.match(/\/i\/spaces\/([^/]+)/);
  58. return urlMatch ? urlMatch[1] : null;
  59. }
  60.  
  61. window.WebSocket = function (url, protocols) {
  62. const ws = new OrigWebSocket(url, protocols);
  63. const originalSend = ws.send;
  64.  
  65. ws.send = function (data) {
  66. if (typeof data === 'string') {
  67. try {
  68. const parsed = JSON.parse(data);
  69. if (parsed.payload && typeof parsed.payload === 'string') {
  70. try {
  71. const payloadParsed = JSON.parse(parsed.payload);
  72. if (payloadParsed.body && selectedCustomEmoji) {
  73. const bodyParsed = JSON.parse(payloadParsed.body);
  74. if (bodyParsed.type === 2) {
  75. bodyParsed.body = selectedCustomEmoji;
  76. payloadParsed.body = JSON.stringify(bodyParsed);
  77. parsed.payload = JSON.stringify(payloadParsed);
  78. data = JSON.stringify(parsed);
  79. if (parsed.sender && parsed.sender.user_id) {
  80. myUserId = parsed.sender.user_id;
  81. }
  82. }
  83. }
  84. } catch (e) {}
  85. }
  86. } catch (e) {}
  87. }
  88. return originalSend.call(this, data);
  89. };
  90.  
  91. let originalOnMessage = null;
  92. ws.onmessage = function (event) {
  93. if (originalOnMessage) originalOnMessage.call(this, event);
  94. try {
  95. const message = JSON.parse(event.data);
  96. if (message.kind !== 1 || !message.payload) return;
  97.  
  98. const payload = JSON.parse(message.payload);
  99. const body = payload.body ? JSON.parse(payload.body) : null;
  100.  
  101. if (payload.room_id) {
  102. currentSpaceId = payload.room_id;
  103. }
  104.  
  105. const urlSpaceId = getSpaceIdFromUrl();
  106. if (urlSpaceId && payload.room_id !== urlSpaceId) return;
  107.  
  108. const participantIndex = body?.guestParticipantIndex || payload.sender?.participant_index || 'unknown';
  109. const displayName = payload.sender?.display_name || body?.displayName || 'Unknown';
  110. const handle = payload.sender?.username || body?.username || 'Unknown';
  111. const timestamp = message.timestamp / 1e6 || Date.now();
  112.  
  113. if ((body?.emoji === '✋' || (body?.body && body.body.includes('✋'))) && body?.type !== 2) {
  114. handQueue.set(participantIndex, { displayName, timestamp });
  115. activeHandRaises.set(participantIndex, timestamp);
  116. } else if (body?.type === 40 && body?.emoji === '') {
  117. if (handQueue.has(participantIndex) && activeHandRaises.has(participantIndex)) {
  118. const startTime = activeHandRaises.get(participantIndex);
  119. const duration = (timestamp - startTime) / 1000;
  120. const sortedQueue = Array.from(handQueue.entries())
  121. .sort(([, a], [, b]) => a.timestamp - b.timestamp);
  122. if (sortedQueue.length > 0 && sortedQueue[0][0] === participantIndex && duration >= 60) {
  123. handRaiseDurations.push(duration);
  124. if (handRaiseDurations.length > 50) {
  125. handRaiseDurations.shift();
  126. }
  127. }
  128. handQueue.delete(participantIndex);
  129. activeHandRaises.delete(participantIndex);
  130. }
  131. } else if (body?.type === 45 && body.body && handQueue.has(participantIndex)) {
  132. const startTime = activeHandRaises.get(participantIndex);
  133. if (startTime) {
  134. const duration = (timestamp - startTime) / 1000;
  135. const sortedQueue = Array.from(handQueue.entries())
  136. .sort(([, a], [, b]) => a.timestamp - b.timestamp);
  137. if (sortedQueue.length > 0 && sortedQueue[0][0] === participantIndex && duration >= 60) {
  138. handRaiseDurations.push(duration);
  139. if (handRaiseDurations.length > 50) {
  140. handRaiseDurations.shift();
  141. }
  142. }
  143. handQueue.delete(participantIndex);
  144. activeHandRaises.delete(participantIndex);
  145. }
  146. }
  147.  
  148. if (body?.type === 45 && body.body) {
  149. const caption = {
  150. displayName,
  151. handle: `@${handle}`,
  152. text: body.body,
  153. timestamp,
  154. uniqueId: `${timestamp}-${displayName}-${handle}-${body.body}`
  155. };
  156. const isDuplicate = captionsData.some(c => c.uniqueId === caption.uniqueId);
  157. const lastCaption = captionsData[captionsData.length - 1];
  158. const isDifferentText = !lastCaption || lastCaption.text !== caption.text;
  159. if (!isDuplicate && isDifferentText) {
  160. captionsData.push(caption);
  161. if (transcriptPopup && transcriptPopup.style.display === 'block') {
  162. updateTranscriptPopup();
  163. }
  164. }
  165. }
  166.  
  167. if (body?.type === 2 && body.body) {
  168. const emojiReaction = {
  169. displayName,
  170. handle: `@${handle}`,
  171. emoji: body.body,
  172. timestamp,
  173. uniqueId: `${timestamp}-${displayName}-${body.body}-${Date.now()}`
  174. };
  175. const isDuplicate = emojiReactions.some(e =>
  176. e.uniqueId === emojiReaction.uniqueId ||
  177. (e.displayName === emojiReaction.displayName &&
  178. e.emoji === emojiReaction.emoji &&
  179. Math.abs(e.timestamp - emojiReaction.timestamp) < 50)
  180. );
  181. if (!isDuplicate) {
  182. emojiReactions.push(emojiReaction);
  183. if (transcriptPopup && transcriptPopup.style.display === 'block') {
  184. debouncedUpdateTranscriptPopup();
  185. }
  186. }
  187. }
  188.  
  189. if (transcriptPopup && transcriptPopup.style.display === 'block') debouncedUpdateTranscriptPopup();
  190. } catch (e) {}
  191. };
  192.  
  193. Object.defineProperty(ws, 'onmessage', {
  194. set: function (callback) {
  195. originalOnMessage = callback;
  196. },
  197. get: function () {
  198. return ws.onmessage;
  199. }
  200. });
  201.  
  202. return ws;
  203. };
  204.  
  205. let transcriptPopup = null;
  206. let transcriptButton = null;
  207. let queueRefreshInterval = null;
  208. const handQueue = new Map();
  209. let lastSpaceState = false;
  210. let lastSpeaker = { username: '', handle: '' };
  211.  
  212. const STORAGE_KEYS = {
  213. LAST_SPACE_ID: 'xSpacesCustomReactions_lastSpaceId',
  214. HAND_DURATIONS: 'xSpacesCustomReactions_handRaiseDurations',
  215. SHOW_EMOJIS: 'xSpacesCustomReactions_showEmojis'
  216. };
  217.  
  218. const debouncedUpdateTranscriptPopup = debounce(updateTranscriptPopup, 2000);
  219.  
  220. function saveSettings() {
  221. localStorage.setItem(STORAGE_KEYS.LAST_SPACE_ID, currentSpaceId || '');
  222. localStorage.setItem(STORAGE_KEYS.HAND_DURATIONS, JSON.stringify(handRaiseDurations));
  223. }
  224.  
  225. function loadSettings() {
  226. lastSpaceId = localStorage.getItem(STORAGE_KEYS.LAST_SPACE_ID) || null;
  227. const savedDurations = localStorage.getItem(STORAGE_KEYS.HAND_DURATIONS);
  228. if (savedDurations) {
  229. handRaiseDurations = JSON.parse(savedDurations);
  230. }
  231. }
  232.  
  233. function hideOriginalEmojiButtons() {
  234. const originalButtons = document.querySelectorAll('.css-175oi2r.r-1awozwy.r-18u37iz.r-9aw3ui.r-1777fci.r-tuq35u > div > button');
  235. originalButtons.forEach(button => {
  236. button.style.display = 'none';
  237. });
  238. }
  239.  
  240. function createEmojiPickerGrid() {
  241. const emojiPicker = document.querySelector('.css-175oi2r.r-1awozwy.r-18u37iz.r-9aw3ui.r-1777fci.r-tuq35u');
  242. if (!emojiPicker) return;
  243.  
  244. if (emojiPicker.querySelector('.emoji-grid-container')) return;
  245.  
  246. hideOriginalEmojiButtons();
  247.  
  248. const gridContainer = document.createElement('div');
  249. gridContainer.className = 'emoji-grid-container';
  250. gridContainer.style.display = 'grid';
  251. gridContainer.style.gridTemplateColumns = 'repeat(5, 1fr)';
  252. gridContainer.style.gap = '10px';
  253. gridContainer.style.padding = '10px';
  254.  
  255. const fragment = document.createDocumentFragment();
  256.  
  257. customEmojis.forEach(emoji => {
  258. const emojiButton = document.createElement('button');
  259. emojiButton.setAttribute('aria-label', `React with ${emoji}`);
  260. emojiButton.setAttribute('role', 'button');
  261. emojiButton.className = 'css-175oi2r r-1awozwy r-z2wwpe r-6koalj r-18u37iz r-1w6e6rj r-a2tzq0 r-tuq35u r-1loqt21 r-o7ynqc r-6416eg r-1ny4l3l';
  262. emojiButton.type = 'button';
  263. emojiButton.style.margin = '5px';
  264.  
  265. const emojiDiv = document.createElement('div');
  266. emojiDiv.dir = 'ltr';
  267. emojiDiv.className = 'css-146c3p1 r-bcqeeo r-1ttztb7 r-qvutc0 r-37j5jr r-1blvdjr r-vrz42v r-16dba41';
  268. emojiDiv.style.color = 'rgb(231, 233, 234)';
  269.  
  270. const emojiImg = document.createElement('img');
  271. emojiImg.alt = emoji;
  272. emojiImg.draggable = 'false';
  273. emojiImg.src = `https://abs-0.twimg.com/emoji/v2/svg/${emoji.codePointAt(0).toString(16)}.svg`;
  274. emojiImg.title = emoji;
  275. emojiImg.className = 'r-4qtqp9 r-dflpy8 r-k4bwe5 r-1kpi4qh r-pp5qcn r-h9hxbl';
  276.  
  277. emojiDiv.appendChild(emojiImg);
  278. emojiButton.appendChild(emojiDiv);
  279.  
  280. emojiButton.addEventListener('click', (e) => {
  281. e.preventDefault();
  282. e.stopPropagation();
  283.  
  284. selectedCustomEmoji = emoji;
  285.  
  286. const originalEmoji = emojiMap.get(emoji);
  287. if (originalEmoji) {
  288. const originalButton = Array.from(document.querySelectorAll('button[aria-label^="React with"]'))
  289. .find(button => button.querySelector('img')?.alt === originalEmoji);
  290. if (originalButton) {
  291. originalButton.click();
  292. }
  293. }
  294. });
  295.  
  296. fragment.appendChild(emojiButton);
  297. });
  298.  
  299. gridContainer.appendChild(fragment);
  300. emojiPicker.appendChild(gridContainer);
  301. }
  302.  
  303. function detectEndedUI() {
  304. const endedContainer = document.querySelector(
  305. 'div[data-testid="sheetDialog"] div.css-175oi2r.r-18u37iz.r-13qz1uu.r-1wtj0ep'
  306. );
  307. if (endedContainer) {
  308. const hasEndedText = Array.from(endedContainer.querySelectorAll('span')).some(
  309. span => span.textContent.toLowerCase().includes('ended')
  310. );
  311. const hasCloseButton = endedContainer.querySelector('button[aria-label="Close"]');
  312. const hasShareButton = endedContainer.querySelector('button[aria-label="Share"]');
  313. if (hasEndedText && hasCloseButton && hasShareButton) {
  314. return endedContainer;
  315. }
  316. }
  317. return null;
  318. }
  319.  
  320. function addDownloadOptionToShareDropdown(dropdown) {
  321. if (dropdown.querySelector('#download-transcript-share')) return;
  322.  
  323. const menuItems = dropdown.querySelectorAll('div[role="menuitem"]');
  324. const itemCount = Array.from(menuItems).filter(item => item.id !== 'download-transcript-share').length;
  325.  
  326. if (itemCount !== 4) return;
  327.  
  328. const downloadItem = document.createElement('div');
  329. downloadItem.id = 'download-transcript-share';
  330. downloadItem.setAttribute('role', 'menuitem');
  331. downloadItem.setAttribute('tabindex', '0');
  332. downloadItem.className = 'css-175oi2r r-1loqt21 r-18u37iz r-1mmae3n r-3pj75a r-13qz1uu r-o7ynqc r-6416eg r-1ny4l3l';
  333. downloadItem.style.transition = 'background-color 0.2s ease';
  334.  
  335. const iconContainer = document.createElement('div');
  336. iconContainer.className = 'css-175oi2r r-1777fci r-faml9v';
  337.  
  338. const downloadIcon = document.createElement('svg');
  339. downloadIcon.viewBox = '0 0 24 24';
  340. downloadIcon.setAttribute('aria-hidden', 'true');
  341. downloadIcon.className = 'r-4qtqp9 r-yyyyoo r-1xvli5t r-dnmrzs r-bnwqim r-lrvibr r-m6rgpd r-1nao33i r-1q142lx';
  342. downloadIcon.innerHTML = '<g><path d="M19 3H5c-1.11 0-2 .89-2 2v14c0 1.11.89 2 2 2h14c1.11 0 2-.89 2-2V5c0-1.11-.89-2-2-2zm-2 16H7v-6h10v6zm2-8H5V5h14v6z"/></g>';
  343. iconContainer.appendChild(downloadIcon);
  344.  
  345. const textContainer = document.createElement('div');
  346. textContainer.className = 'css-175oi2r r-16y2uox r-1wbh5a2';
  347.  
  348. const text = document.createElement('div');
  349. text.dir = 'ltr';
  350. text.className = 'css-146c3p1 r-bcqeeo r-1ttztb7 r-qvutc0 r-37j5jr r-a023e6 r-rjixqe r-b88u0q';
  351. text.style.color = 'rgb(231, 233, 234)';
  352. text.innerHTML = '<span class="css-1jxf684 r-bcqeeo r-1ttztb7 r-qvutc0 r-poiln3">Download Transcript</span>';
  353. textContainer.appendChild(text);
  354.  
  355. downloadItem.appendChild(iconContainer);
  356. downloadItem.appendChild(textContainer);
  357.  
  358. const style = document.createElement('style');
  359. style.textContent = `
  360. #download-transcript-share:hover {
  361. background-color: rgba(231, 233, 234, 0.1);
  362. }
  363. `;
  364. downloadItem.appendChild(style);
  365.  
  366. downloadItem.addEventListener('click', (e) => {
  367. e.preventDefault();
  368. const transcriptContent = formatTranscriptForDownload();
  369. const blob = new Blob([transcriptContent], { type: 'text/plain' });
  370. const url = URL.createObjectURL(blob);
  371. const a = document.createElement('a');
  372. a.href = url;
  373. a.download = `transcript_${new Date().toISOString().replace(/[:.]/g, '-')}.txt`;
  374. document.body.appendChild(a);
  375. a.click();
  376. document.body.removeChild(a);
  377. URL.revokeObjectURL(url);
  378. dropdown.style.display = 'none';
  379. });
  380.  
  381. const shareViaItem = dropdown.querySelector('div[data-testid="share-by-tweet"]');
  382. if (shareViaItem) {
  383. dropdown.insertBefore(downloadItem, shareViaItem.nextSibling);
  384. } else {
  385. dropdown.appendChild(downloadItem);
  386. }
  387. }
  388.  
  389. function updateVisibilityAndPosition() {
  390. const reactionToggle = document.querySelector('button svg path[d="M17 12v3h-2.998v2h3v3h2v-3h3v-2h-3.001v-3H17zm-5 6.839c-3.871-2.34-6.053-4.639-7.127-6.609-1.112-2.04-1.031-3.7-.479-4.82.561-1.13 1.667-1.84 2.91-1.91 1.222-.06 2.68.51 3.892 2.16l.806 1.09.805-1.09c1.211-1.65 2.668-2.22 3.89-2.16 1.242.07 2.347.78 2.908 1.91.334.677.49 1.554.321 2.59h2.011c.153-1.283-.039-2.469-.539-3.48-.887-1.79-2.647-2.91-4.601-3.01-1.65-.09-3.367.56-4.796 2.01-1.43-1.45-3.147-2.1-4.798-2.01-1.954.1-3.714 1.22-4.601 3.01-.896 1.81-.846 4.17.514 6.67 1.353 2.48 4.003 5.12 8.382 7.67l.502.299v-2.32z"]');
  391. const peopleButton = document.querySelector('button svg path[d="M6.662 18H.846l.075-1.069C1.33 11.083 4.335 9 7.011 9c1.416 0 2.66.547 3.656 1.53-1.942 1.373-3.513 3.758-4.004 7.47zM7 8c1.657 0 3-1.346 3-3S8.657 2 7 2 4 3.346 4 5s1.343 3 3 3zm10.616 1.27C18.452 8.63 19 7.632 19 6.5 19 4.57 17.433 3 15.5 3S12 4.57 12 6.5c0 1.132.548 2.13 1.384 2.77.589.451 1.317.73 2.116.73s1.527-.279 2.116-.73zM8.501 19.972l-.029 1.027h14.057l-.029-1.027c-.184-6.618-3.736-8.977-7-8.977s-6.816 2.358-7 8.977z"]');
  392. const isInSpace = reactionToggle !== null || peopleButton !== null;
  393. const endedScreen = Array.from(document.querySelectorAll('.css-146c3p1.r-bcqeeo.r-1ttztb7.r-qvutc0.r-37j5jr.r-1b43r93.r-b88u0q.r-xnfwke.r-tsynxw span.css-1jxf684.r-bcqeeo.r-1ttztb7.r-qvutc0.r-poiln3')).find(span => span.textContent.includes('Ended'));
  394.  
  395. if (isInSpace && !lastSpaceState) {
  396. const urlSpaceId = getSpaceIdFromUrl();
  397. if (urlSpaceId) {
  398. currentSpaceId = urlSpaceId;
  399. if (currentSpaceId !== lastSpaceId) {
  400. handQueue.clear();
  401. activeHandRaises.clear();
  402. captionsData = [];
  403. emojiReactions = [];
  404. lastSpeaker = { username: '', handle: '' };
  405. lastRenderedCaptionCount = 0;
  406. if (transcriptPopup) {
  407. const captionWrapper = transcriptPopup.querySelector('#transcript-output');
  408. if (captionWrapper) captionWrapper.innerHTML = '';
  409. }
  410. } else {
  411. handQueue.clear();
  412. activeHandRaises.clear();
  413. if (transcriptPopup && transcriptPopup.style.display === 'block') {
  414. updateTranscriptPopup();
  415. }
  416. }
  417. lastSpaceId = currentSpaceId;
  418. saveSettings();
  419. }
  420. } else if (!isInSpace && lastSpaceState && !endedScreen) {
  421. currentSpaceId = null;
  422. saveSettings();
  423. activeHandRaises.clear();
  424. }
  425.  
  426. if (isInSpace) {
  427. if (peopleButton) {
  428. const peopleBtn = peopleButton.closest('button');
  429. if (peopleBtn) {
  430. const rect = peopleBtn.getBoundingClientRect();
  431. transcriptButton.style.position = 'fixed';
  432. transcriptButton.style.left = `${rect.left - 46}px`; // Position to the left of the People button (36px width + 10px spacing)
  433. transcriptButton.style.top = `${rect.top}px`;
  434. transcriptButton.style.display = 'block';
  435. }
  436. }
  437. if (reactionToggle) {
  438. createEmojiPickerGrid();
  439. }
  440. } else {
  441. transcriptButton.style.display = 'none';
  442. transcriptPopup.style.display = 'none';
  443. if (queueRefreshInterval) {
  444. clearInterval(queueRefreshInterval);
  445. queueRefreshInterval = null;
  446. }
  447. }
  448.  
  449. const endedContainer = detectEndedUI();
  450. if (endedContainer && lastSpaceState) {
  451. currentSpaceId = null;
  452. saveSettings();
  453. activeHandRaises.clear();
  454. transcriptButton.style.display = 'none';
  455. transcriptPopup.style.display = 'none';
  456. if (queueRefreshInterval) {
  457. clearInterval(queueRefreshInterval);
  458. queueRefreshInterval = null;
  459. }
  460. }
  461.  
  462. lastSpaceState = isInSpace;
  463. }
  464.  
  465. function formatTranscriptForDownload() {
  466. let transcriptText = '';
  467. let previousSpeaker = { username: '', handle: '' };
  468. const combinedData = [
  469. ...captionsData.map(item => ({ ...item, type: 'caption' })),
  470. ...emojiReactions.map(item => ({ ...item, type: 'emoji' }))
  471. ].sort((a, b) => a.timestamp - b.timestamp);
  472.  
  473. combinedData.forEach((item, i) => {
  474. let { displayName, handle } = item;
  475. if (displayName === 'Unknown' && previousSpeaker.username) {
  476. displayName = previousSpeaker.username;
  477. handle = previousSpeaker.handle;
  478. }
  479. if (i > 0 && previousSpeaker.username !== displayName && item.type === 'caption') {
  480. transcriptText += '\n----------------------------------------\n';
  481. }
  482. if (item.type === 'caption') {
  483. transcriptText += `${displayName} ${handle}\n${item.text}\n\n`;
  484. } else if (item.type === 'emoji') {
  485. transcriptText += `${displayName} reacted with ${item.emoji}\n`;
  486. }
  487. previousSpeaker = { username: displayName, handle };
  488. });
  489. return transcriptText;
  490. }
  491.  
  492. let lastRenderedCaptionCount = 0;
  493. let isUserScrolledUp = false;
  494. let currentFontSize = 14;
  495. let searchTerm = '';
  496.  
  497. function filterTranscript(captions, emojis, term) {
  498. if (!term) return { captions, emojis };
  499. const filteredCaptions = captions.filter(caption =>
  500. caption.text.toLowerCase().includes(term.toLowerCase()) ||
  501. caption.displayName.toLowerCase().includes(term.toLowerCase()) ||
  502. caption.handle.toLowerCase().includes(term.toLowerCase())
  503. );
  504. const filteredEmojis = emojis.filter(emoji =>
  505. emoji.emoji.toLowerCase().includes(term.toLowerCase()) ||
  506. emoji.displayName.toLowerCase().includes(term.toLowerCase()) ||
  507. emoji.handle.toLowerCase().includes(term.toLowerCase())
  508. );
  509. return { captions: filteredCaptions, emojis: filteredEmojis };
  510. }
  511.  
  512. function updateTranscriptPopup() {
  513. if (!transcriptPopup) return;
  514.  
  515. let queueContainer = transcriptPopup.querySelector('#queue-container');
  516. let searchContainer = transcriptPopup.querySelector('#search-container');
  517. let scrollArea = transcriptPopup.querySelector('#transcript-scrollable');
  518. let saveButton = transcriptPopup.querySelector('.save-button');
  519. let textSizeContainer = transcriptPopup.querySelector('.text-size-container');
  520. let handQueuePopup = transcriptPopup.querySelector('#hand-queue-popup');
  521. let emojiToggleButton = transcriptPopup.querySelector('#emoji-toggle-button');
  522. let currentScrollTop = scrollArea ? scrollArea.scrollTop : 0;
  523. let wasAtBottom = scrollArea ? (scrollArea.scrollHeight - scrollArea.scrollTop - scrollArea.clientHeight < 50) : true;
  524.  
  525. let showEmojis = localStorage.getItem(STORAGE_KEYS.SHOW_EMOJIS) === 'true' ? true : false;
  526.  
  527. if (!queueContainer || !searchContainer || !scrollArea || !saveButton || !textSizeContainer || !emojiToggleButton) {
  528. transcriptPopup.innerHTML = '';
  529.  
  530. queueContainer = document.createElement('div');
  531. queueContainer.id = 'queue-container';
  532. queueContainer.style.marginBottom = '10px';
  533. transcriptPopup.appendChild(queueContainer);
  534.  
  535. searchContainer = document.createElement('div');
  536. searchContainer.id = 'search-container';
  537. searchContainer.style.display = 'none';
  538. searchContainer.style.marginBottom = '5px';
  539.  
  540. const searchInput = document.createElement('input');
  541. searchInput.type = 'text';
  542. searchInput.placeholder = 'Search transcript...';
  543. searchInput.style.width = '87%';
  544. searchInput.style.padding = '5px';
  545. searchInput.style.backgroundColor = 'rgba(255, 255, 255, 0.1)';
  546. searchInput.style.border = 'none';
  547. searchInput.style.borderRadius = '5px';
  548. searchInput.style.color = 'white';
  549. searchInput.style.fontSize = '14px';
  550. searchInput.addEventListener('input', (e) => {
  551. searchTerm = e.target.value.trim();
  552. updateTranscriptPopup();
  553. });
  554.  
  555. searchContainer.appendChild(searchInput);
  556. transcriptPopup.appendChild(searchContainer);
  557.  
  558. scrollArea = document.createElement('div');
  559. scrollArea.id = 'transcript-scrollable';
  560. scrollArea.style.flex = '1';
  561. scrollArea.style.overflowY = 'auto';
  562. scrollArea.style.maxHeight = '300px';
  563.  
  564. const captionWrapper = document.createElement('div');
  565. captionWrapper.id = 'transcript-output';
  566. captionWrapper.style.color = '#e7e9ea';
  567. captionWrapper.style.fontFamily = 'Arial, sans-serif';
  568. captionWrapper.style.whiteSpace = 'pre-wrap';
  569. captionWrapper.style.fontSize = `${currentFontSize}px`;
  570. scrollArea.appendChild(captionWrapper);
  571.  
  572. const controlsContainer = document.createElement('div');
  573. controlsContainer.style.display = 'flex';
  574. controlsContainer.style.alignItems = 'center';
  575. controlsContainer.style.justifyContent = 'space-between';
  576. controlsContainer.style.padding = '5px 0';
  577. controlsContainer.style.borderTop = '1px solid rgba(255, 255, 255, 0.3)';
  578.  
  579. saveButton = document.createElement('div');
  580. saveButton.className = 'save-button';
  581. saveButton.textContent = '💾 Save Transcript';
  582. saveButton.style.color = '#1DA1F2';
  583. saveButton.style.fontSize = '14px';
  584. saveButton.style.cursor = 'pointer';
  585. saveButton.addEventListener('click', () => {
  586. const transcriptContent = formatTranscriptForDownload();
  587. const blob = new Blob([transcriptContent], { type: 'text/plain' });
  588. const url = URL.createObjectURL(blob);
  589. const a = document.createElement('a');
  590. a.href = url;
  591. a.download = `transcript_${new Date().toISOString().replace(/[:.]/g, '-')}.txt`;
  592. document.body.appendChild(a);
  593. a.click();
  594. document.body.removeChild(a);
  595. URL.revokeObjectURL(url);
  596. });
  597. saveButton.addEventListener('mouseover', () => {
  598. saveButton.style.color = '#FF9800';
  599. });
  600. saveButton.addEventListener('mouseout', () => {
  601. saveButton.style.color = '#1DA1F2';
  602. });
  603.  
  604. textSizeContainer = document.createElement('div');
  605. textSizeContainer.className = 'text-size-container';
  606. textSizeContainer.style.display = 'flex';
  607. textSizeContainer.style.alignItems = 'center';
  608.  
  609. emojiToggleButton = document.createElement('span');
  610. emojiToggleButton.id = 'emoji-toggle-button';
  611. emojiToggleButton.style.position = 'relative';
  612. emojiToggleButton.style.fontSize = '14px';
  613. emojiToggleButton.style.cursor = 'pointer';
  614. emojiToggleButton.style.marginRight = '5px';
  615. emojiToggleButton.style.width = '14px';
  616. emojiToggleButton.style.height = '14px';
  617. emojiToggleButton.style.display = 'inline-flex';
  618. emojiToggleButton.style.alignItems = 'center';
  619. emojiToggleButton.style.justifyContent = 'center';
  620. emojiToggleButton.title = 'Toggle Emoji Notifications';
  621. emojiToggleButton.innerHTML = '🙂';
  622.  
  623. const notAllowedOverlay = document.createElement('span');
  624. notAllowedOverlay.style.position = 'absolute';
  625. notAllowedOverlay.style.width = '14px';
  626. notAllowedOverlay.style.height = '14px';
  627. notAllowedOverlay.style.border = '2px solid red';
  628. notAllowedOverlay.style.borderRadius = '50%';
  629. notAllowedOverlay.style.transform = 'rotate(45deg)';
  630. notAllowedOverlay.style.background = 'transparent';
  631. notAllowedOverlay.style.display = showEmojis ? 'none' : 'block';
  632.  
  633. const slash = document.createElement('span');
  634. slash.style.position = 'absolute';
  635. slash.style.width = '2px';
  636. slash.style.height = '18px';
  637. slash.style.background = 'red';
  638. slash.style.transform = 'rotate(-45deg)';
  639. slash.style.top = '-2px';
  640. slash.style.left = '6px';
  641. notAllowedOverlay.appendChild(slash);
  642.  
  643. emojiToggleButton.appendChild(notAllowedOverlay);
  644.  
  645. emojiToggleButton.addEventListener('click', () => {
  646. showEmojis = !showEmojis;
  647. notAllowedOverlay.style.display = showEmojis ? 'none' : 'block';
  648. localStorage.setItem(STORAGE_KEYS.SHOW_EMOJIS, showEmojis);
  649. updateTranscriptPopup();
  650. });
  651.  
  652. const handEmoji = document.createElement('span');
  653. handEmoji.textContent = '✋';
  654. handEmoji.style.marginRight = '5px';
  655. handEmoji.style.fontSize = '14px';
  656. handEmoji.style.cursor = 'pointer';
  657. handEmoji.title = 'View Speaking Queue';
  658. handEmoji.addEventListener('click', () => {
  659. if (!handQueuePopup) {
  660. handQueuePopup = document.createElement('div');
  661. handQueuePopup.id = 'hand-queue-popup';
  662. handQueuePopup.style.position = 'absolute';
  663. handQueuePopup.style.bottom = '45px';
  664. handQueuePopup.style.right = '0';
  665. handQueuePopup.style.backgroundColor = 'rgba(21, 32, 43, 0.8)';
  666. handQueuePopup.style.borderRadius = '10px';
  667. handQueuePopup.style.padding = '10px';
  668. handQueuePopup.style.zIndex = '10003';
  669. handQueuePopup.style.boxShadow = '0 2px 10px rgba(0, 0, 0, 0.5)';
  670. handQueuePopup.style.width = '200px';
  671. handQueuePopup.style.maxHeight = '200px';
  672. handQueuePopup.style.overflowY = 'auto';
  673. handQueuePopup.style.color = 'white';
  674. handQueuePopup.style.fontSize = '14px';
  675.  
  676. const closeHandButton = document.createElement('button');
  677. closeHandButton.textContent = 'X';
  678. closeHandButton.style.position = 'sticky';
  679. closeHandButton.style.top = '5px';
  680. closeHandButton.style.right = '5px';
  681. closeHandButton.style.float = 'right';
  682. closeHandButton.style.background = 'none';
  683. closeHandButton.style.border = 'none';
  684. closeHandButton.style.color = 'white';
  685. closeHandButton.style.fontSize = '14px';
  686. closeHandButton.style.cursor = 'pointer';
  687. closeHandButton.style.padding = '0';
  688. closeHandButton.style.width = '20px';
  689. closeHandButton.style.height = '20px';
  690. closeHandButton.style.lineHeight = '20px';
  691. closeHandButton.style.textAlign = 'center';
  692. closeHandButton.addEventListener('mouseover', () => {
  693. closeHandButton.style.color = 'red';
  694. });
  695. closeHandButton.addEventListener('mouseout', () => {
  696. closeHandButton.style.color = 'white';
  697. });
  698. closeHandButton.addEventListener('click', (e) => {
  699. e.stopPropagation();
  700. handQueuePopup.style.display = 'none';
  701. });
  702.  
  703. const queueContent = document.createElement('div');
  704. queueContent.id = 'hand-queue-content';
  705. queueContent.style.paddingTop = '10px';
  706.  
  707. handQueuePopup.appendChild(closeHandButton);
  708. handQueuePopup.appendChild(queueContent);
  709. transcriptPopup.appendChild(handQueuePopup);
  710. }
  711.  
  712. handQueuePopup.style.display = handQueuePopup.style.display === 'block' ? 'none' : 'block';
  713. if (handQueuePopup.style.display === 'block') {
  714. updateHandQueueContent(handQueuePopup.querySelector('#hand-queue-content'));
  715. if (queueRefreshInterval) clearInterval(queueRefreshInterval);
  716. queueRefreshInterval = setInterval(() => updateHandQueueContent(handQueuePopup.querySelector('#hand-queue-content')), 1000);
  717. } else if (queueRefreshInterval) {
  718. clearInterval(queueRefreshInterval);
  719. queueRefreshInterval = null;
  720. }
  721. });
  722.  
  723. const magnifierEmoji = document.createElement('span');
  724. magnifierEmoji.textContent = '🔍';
  725. magnifierEmoji.style.marginRight = '5px';
  726. magnifierEmoji.style.fontSize = '14px';
  727. magnifierEmoji.style.cursor = 'pointer';
  728. magnifierEmoji.title = 'Search transcript';
  729. magnifierEmoji.addEventListener('click', () => {
  730. searchContainer.style.display = searchContainer.style.display === 'none' ? 'block' : 'none';
  731. if (searchContainer.style.display === 'block') {
  732. searchInput.focus();
  733. } else {
  734. searchTerm = '';
  735. searchInput.value = '';
  736. updateTranscriptPopup();
  737. }
  738. });
  739.  
  740. const textSizeSlider = document.createElement('input');
  741. textSizeSlider.type = 'range';
  742. textSizeSlider.min = '12';
  743. textSizeSlider.max = '18';
  744. textSizeSlider.value = currentFontSize;
  745. textSizeSlider.style.width = '50px';
  746. textSizeSlider.style.cursor = 'pointer';
  747. textSizeSlider.title = 'Adjust transcript text size';
  748. textSizeSlider.addEventListener('input', () => {
  749. currentFontSize = parseInt(textSizeSlider.value, 10);
  750. const captionWrapper = transcriptPopup.querySelector('#transcript-output');
  751. if (captionWrapper) {
  752. captionWrapper.style.fontSize = `${currentFontSize}px`;
  753. const allSpans = captionWrapper.querySelectorAll('span');
  754. allSpans.forEach(span => {
  755. span.style.fontSize = `${currentFontSize}px`;
  756. });
  757. }
  758. localStorage.setItem('xSpacesCustomReactions_textSize', currentFontSize);
  759. });
  760.  
  761. const savedTextSize = localStorage.getItem('xSpacesCustomReactions_textSize');
  762. if (savedTextSize) {
  763. currentFontSize = parseInt(savedTextSize, 10);
  764. textSizeSlider.value = currentFontSize;
  765. captionWrapper.style.fontSize = `${currentFontSize}px`;
  766. }
  767.  
  768. textSizeContainer.appendChild(emojiToggleButton);
  769. textSizeContainer.appendChild(handEmoji);
  770. textSizeContainer.appendChild(magnifierEmoji);
  771. textSizeContainer.appendChild(textSizeSlider);
  772.  
  773. controlsContainer.appendChild(saveButton);
  774. controlsContainer.appendChild(textSizeContainer);
  775.  
  776. transcriptPopup.appendChild(queueContainer);
  777. transcriptPopup.appendChild(searchContainer);
  778. transcriptPopup.appendChild(scrollArea);
  779. transcriptPopup.appendChild(controlsContainer);
  780. lastRenderedCaptionCount = 0;
  781. }
  782.  
  783. const { captions: filteredCaptions, emojis: filteredEmojis } = filterTranscript(captionsData, emojiReactions, searchTerm);
  784. const totalItems = filteredCaptions.length + (showEmojis ? filteredEmojis.length : 0);
  785.  
  786. const captionWrapper = scrollArea.querySelector('#transcript-output');
  787. if (captionWrapper) {
  788. captionWrapper.innerHTML = '';
  789. let previousSpeaker = lastSpeaker;
  790.  
  791. const combinedData = [
  792. ...filteredCaptions.map(item => ({ ...item, type: 'caption' })),
  793. ...(showEmojis ? filteredEmojis.map(item => ({ ...item, type: 'emoji' })) : [])
  794. ].sort((a, b) => a.timestamp - b.timestamp);
  795.  
  796. let emojiGroups = [];
  797. let currentGroup = null;
  798.  
  799. combinedData.forEach((item) => {
  800. if (item.type === 'caption') {
  801. if (currentGroup) {
  802. emojiGroups.push(currentGroup);
  803. currentGroup = null;
  804. }
  805. emojiGroups.push(item);
  806. } else if (item.type === 'emoji' && showEmojis) {
  807. if (!currentGroup) {
  808. currentGroup = { displayName: item.displayName, emoji: item.emoji, count: 1, items: [item] };
  809. } else if (currentGroup.displayName === item.displayName &&
  810. currentGroup.emoji === item.emoji &&
  811. Math.abs(item.timestamp - currentGroup.items[currentGroup.items.length - 1].timestamp) < 50) {
  812. currentGroup.count++;
  813. currentGroup.items.push(item);
  814. } else {
  815. emojiGroups.push(currentGroup);
  816. currentGroup = { displayName: item.displayName, emoji: item.emoji, count: 1, items: [item] };
  817. }
  818. }
  819. });
  820. if (currentGroup) emojiGroups.push(currentGroup);
  821.  
  822. emojiGroups.forEach((group, i) => {
  823. if (group.type === 'caption') {
  824. let { displayName, handle, text } = group;
  825. if (displayName === 'Unknown' && previousSpeaker.username) {
  826. displayName = previousSpeaker.username;
  827. handle = previousSpeaker.handle;
  828. }
  829. if (i > 0 && previousSpeaker.username !== displayName) {
  830. captionWrapper.insertAdjacentHTML('beforeend', '<div style="border-top: 1px solid rgba(255, 255, 255, 0.3); margin: 5px 0;"></div>');
  831. }
  832. captionWrapper.insertAdjacentHTML('beforeend',
  833. `<span style="font-size: ${currentFontSize}px; color: #1DA1F2">${displayName}</span> ` +
  834. `<span style="font-size: ${currentFontSize}px; color: #808080">${handle}</span><br>` +
  835. `<span style="font-size: ${currentFontSize}px; color: #FFFFFF">${text}</span><br><br>`
  836. );
  837. previousSpeaker = { username: displayName, handle };
  838. } else if (showEmojis) {
  839. let { displayName, emoji, count } = group;
  840. if (displayName === 'Unknown' && previousSpeaker.username) {
  841. displayName = previousSpeaker.username;
  842. }
  843. const countText = count > 1 ? ` <span style="font-size: ${currentFontSize}px; color: #FFD700">x${count}</span>` : '';
  844. captionWrapper.insertAdjacentHTML('beforeend',
  845. `<span style="font-size: ${currentFontSize}px; color: #FFD700">${displayName}</span> ` +
  846. `<span style="font-size: ${currentFontSize}px; color: #FFFFFF">reacted with ${emoji}${countText}</span><br>`
  847. );
  848. previousSpeaker = { username: displayName, handle: group.items[0].handle };
  849. }
  850. });
  851.  
  852. lastSpeaker = previousSpeaker;
  853. lastRenderedCaptionCount = totalItems;
  854. }
  855.  
  856. if (wasAtBottom && !searchTerm) {
  857. scrollArea.scrollTop = scrollArea.scrollHeight;
  858. } else {
  859. scrollArea.scrollTop = currentScrollTop;
  860. }
  861.  
  862. scrollArea.onscroll = () => {
  863. isUserScrolledUp = scrollArea.scrollHeight - scrollArea.scrollTop - scrollArea.clientHeight > 50;
  864. };
  865.  
  866. if (handQueuePopup && handQueuePopup.style.display === 'block') {
  867. updateHandQueueContent(handQueuePopup.querySelector('#hand-queue-content'));
  868. }
  869. }
  870.  
  871. function updateHandQueueContent(queueContent) {
  872. if (!queueContent) return;
  873. queueContent.innerHTML = '<strong>Speaking Queue</strong><br>';
  874. if (handQueue.size === 0) {
  875. queueContent.innerHTML += 'No hands raised.<br>';
  876. } else {
  877. const now = Date.now();
  878. const sortedQueue = Array.from(handQueue.entries())
  879. .sort(([, a], [, b]) => a.timestamp - b.timestamp);
  880.  
  881. const queueList = document.createElement('div');
  882. queueList.style.display = 'flex';
  883. queueList.style.flexDirection = 'column';
  884. queueList.style.gap = '8px';
  885.  
  886. const numberEmojis = ['1️⃣', '2️⃣', '3️⃣', '4️⃣', '5️⃣', '6️⃣', '7️⃣', '8️⃣', '9️⃣', '🔟'];
  887.  
  888. sortedQueue.forEach(([, { displayName, timestamp }], index) => {
  889. const timeUp = Math.floor((now - timestamp) / 1000);
  890. let timeStr;
  891. if (timeUp >= 3600) {
  892. const hours = Math.floor(timeUp / 3600);
  893. const minutes = Math.floor((timeUp % 3600) / 60);
  894. const seconds = timeUp % 60;
  895. timeStr = `${hours}h ${minutes}m ${seconds}s`;
  896. } else {
  897. const minutes = Math.floor(timeUp / 60);
  898. const seconds = timeUp % 60;
  899. timeStr = minutes > 0 ? `${minutes}m ${seconds}s` : `${seconds}s`;
  900. }
  901.  
  902. const entry = document.createElement('div');
  903. entry.style.display = 'flex';
  904. entry.style.alignItems = 'center';
  905. entry.style.justifyContent = 'space-between';
  906.  
  907. const text = document.createElement('span');
  908. const positionEmoji = index < 10 ? numberEmojis[index] : '';
  909. text.textContent = `${positionEmoji} ${displayName}: ${timeStr}`;
  910.  
  911. entry.appendChild(text);
  912. queueList.appendChild(entry);
  913. });
  914.  
  915. queueContent.appendChild(queueList);
  916. }
  917.  
  918. if (handRaiseDurations.length > 0) {
  919. const averageContainer = document.createElement('div');
  920. averageContainer.style.color = 'red';
  921. averageContainer.style.fontSize = '12px';
  922. averageContainer.style.marginTop = '10px';
  923. averageContainer.style.textAlign = 'right';
  924.  
  925. const averageSeconds = handRaiseDurations.reduce((a, b) => a + b, 0) / handRaiseDurations.length;
  926. let avgStr;
  927. if (averageSeconds >= 3600) {
  928. const hours = Math.floor(averageSeconds / 3600);
  929. const minutes = Math.floor((averageSeconds % 3600) / 60);
  930. const seconds = Math.floor(averageSeconds % 60);
  931. avgStr = `${hours}h ${minutes}m ${seconds}s`;
  932. } else {
  933. const minutes = Math.floor(averageSeconds / 60);
  934. const seconds = Math.floor(averageSeconds % 60);
  935. avgStr = minutes > 0 ? `${minutes}m ${seconds}s` : `${seconds}s`;
  936. }
  937. averageContainer.textContent = `Average Wait: ${avgStr}`;
  938.  
  939. queueContent.appendChild(averageContainer);
  940. }
  941. }
  942.  
  943. function init() {
  944. transcriptButton = document.createElement('button');
  945. transcriptButton.textContent = '📜';
  946. transcriptButton.style.zIndex = '10001';
  947. transcriptButton.style.fontSize = '18px';
  948. transcriptButton.style.padding = '0';
  949. transcriptButton.style.backgroundColor = 'transparent';
  950. transcriptButton.style.border = '0.3px solid #40648085';
  951. transcriptButton.style.borderRadius = '50%';
  952. transcriptButton.style.width = '36px';
  953. transcriptButton.style.height = '36px';
  954. transcriptButton.style.cursor = 'pointer';
  955. transcriptButton.style.display = 'none';
  956. transcriptButton.style.lineHeight = '32px';
  957. transcriptButton.style.textAlign = 'center';
  958. transcriptButton.style.position = 'fixed';
  959. transcriptButton.style.color = 'white';
  960. transcriptButton.style.filter = 'grayscale(100%) brightness(200%)';
  961. transcriptButton.title = 'Transcript';
  962.  
  963. transcriptButton.addEventListener('mouseover', () => {
  964. transcriptButton.style.backgroundColor = '#595b5b40';
  965. });
  966. transcriptButton.addEventListener('mouseout', () => {
  967. transcriptButton.style.backgroundColor = 'transparent';
  968. });
  969.  
  970. transcriptButton.addEventListener('click', () => {
  971. const isVisible = transcriptPopup.style.display === 'block';
  972. transcriptPopup.style.display = isVisible ? 'none' : 'block';
  973. if (!isVisible) updateTranscriptPopup();
  974. });
  975.  
  976. transcriptPopup = document.createElement('div');
  977. transcriptPopup.style.position = 'fixed';
  978. transcriptPopup.style.bottom = '150px';
  979. transcriptPopup.style.right = '20px';
  980. transcriptPopup.style.backgroundColor = 'rgba(21, 32, 43, 0.9)';
  981. transcriptPopup.style.borderRadius = '10px';
  982. transcriptPopup.style.padding = '10px';
  983. transcriptPopup.style.zIndex = '10002';
  984. transcriptPopup.style.maxHeight = '400px';
  985. transcriptPopup.style.display = 'none';
  986. transcriptPopup.style.width = '270px';
  987. transcriptPopup.style.color = 'white';
  988. transcriptPopup.style.fontSize = '14px';
  989. transcriptPopup.style.lineHeight = '1.5';
  990. transcriptPopup.style.boxShadow = '0 2px 10px rgba(0, 0, 0, 0.5)';
  991. transcriptPopup.style.flexDirection = 'column';
  992.  
  993. document.body.appendChild(transcriptButton);
  994. document.body.appendChild(transcriptPopup);
  995.  
  996. loadSettings();
  997.  
  998. const observer = new MutationObserver((mutationsList) => {
  999. for (const mutation of mutationsList) {
  1000. if (mutation.type === 'childList') {
  1001. updateVisibilityAndPosition();
  1002.  
  1003. const dropdown = document.querySelector('div[data-testid="Dropdown"]');
  1004. if (dropdown && dropdown.closest('[role="menu"]') && (captionsData.length > 0 || emojiReactions.length > 0)) {
  1005. addDownloadOptionToShareDropdown(dropdown);
  1006. }
  1007. }
  1008. }
  1009. });
  1010.  
  1011. observer.observe(document.body, { childList: true, subtree: true });
  1012. updateVisibilityAndPosition();
  1013. setInterval(updateVisibilityAndPosition, 2000);
  1014. }
  1015.  
  1016. if (document.readyState === 'loading') {
  1017. document.addEventListener('DOMContentLoaded', init);
  1018. } else {
  1019. init();
  1020. }
  1021. })();