X Spaces + r

Addon for X Spaces with custom emojis, enhanced transcript including mute/unmute, hand raise/lower, mic invites, join/leave events, and speaker queuing.

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

  1. // ==UserScript==
  2. // @name X Spaces + r
  3. // @namespace Violentmonkey Scripts
  4. // @version 1.91
  5. // @description Addon for X Spaces with custom emojis, enhanced transcript including mute/unmute, hand raise/lower, mic invites, join/leave events, 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. // [Previous unchanged code omitted for brevity: WebSocket, XMLHttpRequest, variables, fetchReplayUrl, debounce, getSpaceIdFromUrl, etc.]
  17.  
  18. // [Keeping all other functions unchanged until formatTranscriptForDownload]
  19.  
  20. async function formatTranscriptForDownload() {
  21. let transcriptText = '--- Space URLs ---\n';
  22. // Append live URL
  23. if (dynamicUrl) {
  24. transcriptText += `Live URL: ${dynamicUrl}\n`;
  25. } else {
  26. transcriptText += 'Live URL: Not available\n';
  27. }
  28.  
  29. // Append replay URL (async fetch)
  30. try {
  31. const replayUrl = await fetchReplayUrl(dynamicUrl);
  32. transcriptText += `Replay URL: ${replayUrl}\n`;
  33. } catch (e) {
  34. transcriptText += 'Replay URL: Failed to generate\n';
  35. }
  36.  
  37. transcriptText += '-----------------\n\n';
  38.  
  39. let previousSpeaker = { username: '', handle: '' };
  40. const combinedData = [
  41. ...captionsData.map(item => ({ ...item, type: 'caption' })),
  42. ...emojiReactions.map(item => ({ ...item, type: 'emoji' }))
  43. ].sort((a, b) => a.timestamp - b.timestamp);
  44.  
  45. combinedData.forEach((item, i) => {
  46. let { displayName, handle } = item;
  47. if (displayName === 'Unknown' && previousSpeaker.username) {
  48. displayName = previousSpeaker.username;
  49. handle = previousSpeaker.handle;
  50. }
  51. if (i > 0 && previousSpeaker.username !== displayName && item.type === 'caption') {
  52. transcriptText += '\n----------------------------------------\n';
  53. }
  54. if (item.type === 'caption') {
  55. transcriptText += `${displayName} ${handle}\n${item.text}\n\n`;
  56. } else if (item.type === 'emoji') {
  57. transcriptText += `${displayName} reacted with ${item.emoji}\n`;
  58. }
  59. previousSpeaker = { username: displayName, handle };
  60. });
  61. return transcriptText;
  62. }
  63.  
  64. // [Unchanged functions: filterTranscript]
  65.  
  66. function updateTranscriptPopup() {
  67. if (!transcriptPopup || transcriptPopup.style.display !== 'block') return;
  68.  
  69. let queueContainer = transcriptPopup.querySelector('#queue-container');
  70. let searchContainer = transcriptPopup.querySelector('#search-container');
  71. let scrollArea = transcriptPopup.querySelector('#transcript-scrollable');
  72. let saveButton = transcriptPopup.querySelector('.save-button');
  73. let textSizeContainer = transcriptPopup.querySelector('.text-size-container');
  74. let systemToggleButton = transcriptPopup.querySelector('#system-toggle-button');
  75. let emojiToggleButton = transcriptPopup.querySelector('#emoji-toggle-button');
  76. let currentScrollTop = scrollArea ? scrollArea.scrollTop : 0;
  77. let wasAtBottom = scrollArea ? (scrollArea.scrollHeight - scrollArea.scrollTop - scrollArea.clientHeight < 50) : true;
  78.  
  79. let showEmojis = localStorage.getItem(STORAGE_KEYS.SHOW_EMOJIS) !== 'false';
  80. let showSystemMessages = localStorage.getItem(STORAGE_KEYS.SHOW_SYSTEM_MESSAGES) !== 'false';
  81.  
  82. if (!queueContainer || !searchContainer || !scrollArea || !saveButton || !textSizeContainer || !systemToggleButton || !emojiToggleButton) {
  83. transcriptPopup.innerHTML = '';
  84.  
  85. queueContainer = document.createElement('div');
  86. queueContainer.id = 'queue-container';
  87. queueContainer.style.marginBottom = '10px';
  88. transcriptPopup.appendChild(queueContainer);
  89.  
  90. searchContainer = document.createElement('div');
  91. searchContainer.id = 'search-container';
  92. searchContainer.style.display = 'none';
  93. searchContainer.style.marginBottom = '5px';
  94.  
  95. const searchInput = document.createElement('input');
  96. searchInput.type = 'text';
  97. searchInput.placeholder = 'Search transcript...';
  98. searchInput.style.width = '87%';
  99. searchInput.style.padding = '5px';
  100. searchInput.style.backgroundColor = 'rgba(255, 255, 255, 0.1)';
  101. searchInput.style.border = 'none';
  102. searchInput.style.borderRadius = '5px';
  103. searchInput.style.color = 'white';
  104. searchInput.style.fontSize = '14px';
  105. searchInput.addEventListener('input', (e) => {
  106. searchTerm = e.target.value.trim();
  107. updateTranscriptPopup();
  108. });
  109.  
  110. searchContainer.appendChild(searchInput);
  111. transcriptPopup.appendChild(searchContainer);
  112.  
  113. scrollArea = document.createElement('div');
  114. scrollArea.id = 'transcript-scrollable';
  115. scrollArea.style.flex = '1';
  116. scrollArea.style.overflowY = 'auto';
  117. scrollArea.style.maxHeight = '300px';
  118.  
  119. const captionWrapper = document.createElement('div');
  120. captionWrapper.id = 'transcript-output';
  121. captionWrapper.style.color = '#e7e9ea';
  122. captionWrapper.style.fontFamily = 'Arial, sans-serif';
  123. captionWrapper.style.whiteSpace = 'pre-wrap';
  124. captionWrapper.style.fontSize = `${currentFontSize}px`;
  125. scrollArea.appendChild(captionWrapper);
  126.  
  127. const controlsContainer = document.createElement('div');
  128. controlsContainer.style.display = 'flex';
  129. controlsContainer.style.alignItems = 'center';
  130. controlsContainer.style.justifyContent = 'space-between';
  131. controlsContainer.style.padding = '5px 0';
  132. controlsContainer.style.borderTop = '1px solid rgba(255, 255, 255, 0.3)';
  133.  
  134. saveButton = document.createElement('div');
  135. saveButton.className = 'save-button';
  136. saveButton.textContent = '💾 Save Transcript';
  137. saveButton.style.color = '#1DA1F2';
  138. saveButton.style.fontSize = '14px';
  139. saveButton.style.cursor = 'pointer';
  140. saveButton.addEventListener('click', async () => { // Updated to async
  141. saveButton.textContent = '💾 Saving...'; // Feedback during async operation
  142. const transcriptContent = await formatTranscriptForDownload();
  143. const blob = new Blob([transcriptContent], { type: 'text/plain' });
  144. const url = URL.createObjectURL(blob);
  145. const a = document.createElement('a');
  146. a.href = url;
  147. a.download = `transcript_${new Date().toISOString().replace(/[:.]/g, '-')}.txt`;
  148. document.body.appendChild(a);
  149. a.click();
  150. document.body.removeChild(a);
  151. URL.revokeObjectURL(url);
  152. saveButton.textContent = '💾 Save Transcript'; // Reset button text
  153. });
  154. saveButton.addEventListener('mouseover', () => saveButton.style.color = '#FF9800');
  155. saveButton.addEventListener('mouseout', () => saveButton.style.color = '#1DA1F2');
  156.  
  157. textSizeContainer = document.createElement('div');
  158. textSizeContainer.className = 'text-size-container';
  159. textSizeContainer.style.display = 'flex';
  160. textSizeContainer.style.alignItems = 'center';
  161.  
  162. systemToggleButton = document.createElement('span');
  163. systemToggleButton.id = 'system-toggle-button';
  164. systemToggleButton.style.position = 'relative';
  165. systemToggleButton.style.fontSize = '14px';
  166. systemToggleButton.style.cursor = 'pointer';
  167. systemToggleButton.style.marginRight = '5px';
  168. systemToggleButton.style.width = '14px';
  169. systemToggleButton.style.height = '14px';
  170. systemToggleButton.style.display = 'inline-flex';
  171. systemToggleButton.style.alignItems = 'center';
  172. systemToggleButton.style.justifyContent = 'center';
  173. systemToggleButton.title = 'Toggle System Messages';
  174. systemToggleButton.innerHTML = '📢';
  175.  
  176. const systemNotAllowedOverlay = document.createElement('span');
  177. systemNotAllowedOverlay.style.position = 'absolute';
  178. systemNotAllowedOverlay.style.width = '14px';
  179. systemNotAllowedOverlay.style.height = '14px';
  180. systemNotAllowedOverlay.style.border = '2px solid red';
  181. systemNotAllowedOverlay.style.borderRadius = '50%';
  182. systemNotAllowedOverlay.style.transform = 'rotate(45deg)';
  183. systemNotAllowedOverlay.style.background = 'transparent';
  184. systemNotAllowedOverlay.style.display = showSystemMessages ? 'none' : 'block';
  185.  
  186. const systemSlash = document.createElement('span');
  187. systemSlash.style.position = 'absolute';
  188. systemSlash.style.width = '2px';
  189. systemSlash.style.height = '18px';
  190. systemSlash.style.background = 'red';
  191. systemSlash.style.transform = 'rotate(-45deg)';
  192. systemSlash.style.top = '-2px';
  193. systemSlash.style.left = '6px';
  194. systemNotAllowedOverlay.appendChild(systemSlash);
  195.  
  196. systemToggleButton.appendChild(systemNotAllowedOverlay);
  197.  
  198. systemToggleButton.addEventListener('click', () => {
  199. showSystemMessages = !showSystemMessages;
  200. systemNotAllowedOverlay.style.display = showSystemMessages ? 'none' : 'block';
  201. localStorage.setItem(STORAGE_KEYS.SHOW_SYSTEM_MESSAGES, showSystemMessages);
  202. updateTranscriptPopup();
  203. });
  204.  
  205. emojiToggleButton = document.createElement('span');
  206. emojiToggleButton.id = 'emoji-toggle-button';
  207. emojiToggleButton.style.position = 'relative';
  208. emojiToggleButton.style.fontSize = '14px';
  209. emojiToggleButton.style.cursor = 'pointer';
  210. emojiToggleButton.style.marginRight = '5px';
  211. emojiToggleButton.style.width = '14px';
  212. emojiToggleButton.style.height = '14px';
  213. emojiToggleButton.style.display = 'inline-flex';
  214. emojiToggleButton.style.alignItems = 'center';
  215. emojiToggleButton.style.justifyContent = 'center';
  216. emojiToggleButton.title = 'Toggle Emoji Capturing';
  217. emojiToggleButton.innerHTML = '🙂';
  218.  
  219. const emojiNotAllowedOverlay = document.createElement('span');
  220. emojiNotAllowedOverlay.style.position = 'absolute';
  221. emojiNotAllowedOverlay.style.width = '14px';
  222. emojiNotAllowedOverlay.style.height = '14px';
  223. emojiNotAllowedOverlay.style.border = '2px solid red';
  224. emojiNotAllowedOverlay.style.borderRadius = '50%';
  225. emojiNotAllowedOverlay.style.transform = 'rotate(45deg)';
  226. emojiNotAllowedOverlay.style.background = 'transparent';
  227. emojiNotAllowedOverlay.style.display = showEmojis ? 'none' : 'block';
  228.  
  229. const emojiSlash = document.createElement('span');
  230. emojiSlash.style.position = 'absolute';
  231. emojiSlash.style.width = '2px';
  232. emojiSlash.style.height = '18px';
  233. emojiSlash.style.background = 'red';
  234. emojiSlash.style.transform = 'rotate(-45deg)';
  235. emojiSlash.style.top = '-2px';
  236. emojiSlash.style.left = '6px';
  237. emojiNotAllowedOverlay.appendChild(emojiSlash);
  238.  
  239. emojiToggleButton.appendChild(emojiNotAllowedOverlay);
  240.  
  241. emojiToggleButton.addEventListener('click', () => {
  242. showEmojis = !showEmojis;
  243. emojiNotAllowedOverlay.style.display = showEmojis ? 'none' : 'block';
  244. localStorage.setItem(STORAGE_KEYS.SHOW_EMOJIS, showEmojis);
  245. updateTranscriptPopup();
  246. });
  247.  
  248. const magnifierEmoji = document.createElement('span');
  249. magnifierEmoji.textContent = '🔍';
  250. magnifierEmoji.style.marginRight = '5px';
  251. magnifierEmoji.style.fontSize = '14px';
  252. magnifierEmoji.style.cursor = 'pointer';
  253. magnifierEmoji.title = 'Search transcript';
  254. magnifierEmoji.addEventListener('click', () => {
  255. searchContainer.style.display = searchContainer.style.display === 'none' ? 'block' : 'none';
  256. if (searchContainer.style.display === 'block') searchInput.focus();
  257. else {
  258. searchTerm = '';
  259. searchInput.value = '';
  260. updateTranscriptPopup();
  261. }
  262. });
  263.  
  264. const textSizeSlider = document.createElement('input');
  265. textSizeSlider.type = 'range';
  266. textSizeSlider.min = '12';
  267. textSizeSlider.max = '18';
  268. textSizeSlider.value = currentFontSize;
  269. textSizeSlider.style.width = '50px';
  270. textSizeSlider.style.cursor = 'pointer';
  271. textSizeSlider.title = 'Adjust transcript text size';
  272. textSizeSlider.addEventListener('input', () => {
  273. currentFontSize = parseInt(textSizeSlider.value, 10);
  274. const captionWrapper = transcriptPopup.querySelector('#transcript-output');
  275. if (captionWrapper) captionWrapper.style.fontSize = `${currentFontSize}px`;
  276. localStorage.setItem('xSpacesCustomReactions_textSize', currentFontSize);
  277. });
  278.  
  279. const savedTextSize = localStorage.getItem('xSpacesCustomReactions_textSize');
  280. if (savedTextSize) {
  281. currentFontSize = parseInt(savedTextSize, 10);
  282. textSizeSlider.value = currentFontSize;
  283. }
  284.  
  285. textSizeContainer.appendChild(systemToggleButton);
  286. textSizeContainer.appendChild(emojiToggleButton);
  287. textSizeContainer.appendChild(magnifierEmoji);
  288. textSizeContainer.appendChild(textSizeSlider);
  289.  
  290. controlsContainer.appendChild(saveButton);
  291. controlsContainer.appendChild(textSizeContainer);
  292.  
  293. transcriptPopup.appendChild(queueContainer);
  294. transcriptPopup.appendChild(searchContainer);
  295. transcriptPopup.appendChild(scrollArea);
  296. transcriptPopup.appendChild(controlsContainer);
  297. }
  298.  
  299. const { captions: filteredCaptions, emojis: filteredEmojis } = filterTranscript(captionsData, emojiReactions, searchTerm);
  300. const combinedData = [
  301. ...filteredCaptions.map(item => ({ ...item, type: 'caption' })),
  302. ...(showEmojis ? filteredEmojis.map(item => ({ ...item, type: 'emoji' })) : [])
  303. ].sort((a, b) => a.timestamp - b.timestamp);
  304.  
  305. // Find the previous speaker before the last 200 entries
  306. let previousSpeaker = lastSpeaker || { username: '', handle: '' };
  307. if (combinedData.length > 200) {
  308. for (let i = combinedData.length - 201; i >= 0; i--) {
  309. if (combinedData[i].type === 'caption') {
  310. previousSpeaker = { username: combinedData[i].displayName, handle: combinedData[i].handle };
  311. break;
  312. }
  313. }
  314. }
  315.  
  316. // Limit to the last 200 entries
  317. const recentData = combinedData.slice(-200);
  318.  
  319. // Group consecutive emojis within the 200 entries
  320. let emojiGroups = [];
  321. let currentGroup = null;
  322. recentData.forEach(item => {
  323. if (item.type === 'caption') {
  324. if (currentGroup) {
  325. emojiGroups.push(currentGroup);
  326. currentGroup = null;
  327. }
  328. emojiGroups.push(item);
  329. } else if (item.type === 'emoji' && showEmojis) {
  330. if (currentGroup && currentGroup.displayName === item.displayName && currentGroup.emoji === item.emoji &&
  331. Math.abs(item.timestamp - currentGroup.items[currentGroup.items.length - 1].timestamp) < 50) {
  332. currentGroup.count++;
  333. currentGroup.items.push(item);
  334. } else {
  335. if (currentGroup) emojiGroups.push(currentGroup);
  336. currentGroup = { displayName: item.displayName, emoji: item.emoji, count: 1, items: [item] };
  337. }
  338. }
  339. });
  340. if (currentGroup) emojiGroups.push(currentGroup);
  341.  
  342. // Build the HTML string
  343. let html = '';
  344. if (combinedData.length > 200) {
  345. html += '<div style="color: #FFD700; font-size: 12px; margin-bottom: 10px;">Showing the last 200 lines. Save transcript to see the full conversation.</div>';
  346. }
  347. emojiGroups.forEach((group, i) => {
  348. if (group.type === 'caption') {
  349. let { displayName, handle, text } = group;
  350. if (displayName === 'Unknown' && previousSpeaker.username) {
  351. displayName = previousSpeaker.username;
  352. handle = previousSpeaker.handle;
  353. }
  354. if (i > 0 && previousSpeaker.username !== displayName) {
  355. html += '<div style="border-top: 1px solid rgba(255, 255, 255, 0.3); margin: 5px 0;"></div>';
  356. }
  357. html += `<span style="font-size: ${currentFontSize}px; color: #1DA1F2">${displayName}</span> ` +
  358. `<span style="font-size: ${currentFontSize}px; color: #808080">${handle}</span><br>` +
  359. `<span style="font-size: ${currentFontSize}px; color: ${displayName === 'System' ? '#FF4500' : '#FFFFFF'}">${text}</span><br><br>`;
  360. previousSpeaker = { username: displayName, handle };
  361. } else if (showEmojis) {
  362. let { displayName, emoji, count } = group;
  363. if (displayName === 'Unknown' && previousSpeaker.username) {
  364. displayName = previousSpeaker.username;
  365. }
  366. const countText = count > 1 ? ` <span style="font-size: ${currentFontSize}px; color: #FFD700">x${count}</span>` : '';
  367. html += `<span style="font-size: ${currentFontSize}px; color: #FFD700">${displayName}</span> ` +
  368. `<span style="font-size: ${currentFontSize}px; color: #FFFFFF">reacted with ${emoji}${countText}</span><br>`;
  369. previousSpeaker = { username: displayName, handle: group.items[0].handle };
  370. }
  371. });
  372.  
  373. // Update the DOM once
  374. const captionWrapper = scrollArea.querySelector('#transcript-output');
  375. if (captionWrapper) {
  376. captionWrapper.innerHTML = html;
  377. lastSpeaker = previousSpeaker;
  378.  
  379. // Maintain scroll position
  380. if (wasAtBottom && !searchTerm) scrollArea.scrollTop = scrollArea.scrollHeight;
  381. else scrollArea.scrollTop = currentScrollTop;
  382.  
  383. scrollArea.onscroll = () => {
  384. isUserScrolledUp = scrollArea.scrollHeight - scrollArea.scrollTop - scrollArea.clientHeight > 50;
  385. };
  386. }
  387.  
  388. if (handQueuePopup && handQueuePopup.style.display === 'block') {
  389. updateHandQueueContent(handQueuePopup.querySelector('#hand-queue-content'));
  390. }
  391. }
  392.  
  393. // [Unchanged functions: updateHandQueueContent, init, etc. omitted for brevity]
  394.  
  395. if (document.readyState === 'loading') {
  396. document.addEventListener('DOMContentLoaded', init);
  397. } else {
  398. init();
  399. }
  400. })();