Twitter & YouTube v1

添加圆形按钮,显示“顶”和“底”文字,居中于渐变背景,滚动到页面顶部/底部。Twitter (X) 首页点击“顶”按钮滚动顶部、显示圆环动画并刷新时间轴;YouTube 任意滑动时提前预加载两屏内容,确保无缝滚动,点击“顶”刷新页面;其他页面点击“顶”刷新并显示动画。长按300毫秒编辑按钮位置,保持60像素间距,松手保存,正常状态下按钮固定。

  1. // ==UserScript==
  2. // @name Twitter (X) & YouTube Refresh with Scroll Top/Bottom
  3. // @name:zh-CN Twitter & YouTube v1
  4. // @namespace https://gist.github.com/4lrick/bedb39b069be0e4c94dc20214137c9f5
  5. // @version 2.50
  6. // @description Adds circular buttons with '顶' (top) and '底' (bottom) text, centered on gradient background, for scrolling to page top/bottom. On Twitter (X), top button scrolls up, shows ring animation, and refreshes timeline. On YouTube, preloads two screen heights of content ahead during any scroll for seamless scrolling, with same refresh animation. Other pages refresh with animation. Long press (300ms) to edit button positions, maintaining 60px spacing. Release to save; buttons fixed otherwise.
  7. // @description:zh-CN 添加圆形按钮,显示“顶”和“底”文字,居中于渐变背景,滚动到页面顶部/底部。Twitter (X) 首页点击“顶”按钮滚动顶部、显示圆环动画并刷新时间轴;YouTube 任意滑动时提前预加载两屏内容,确保无缝滚动,点击“顶”刷新页面;其他页面点击“顶”刷新并显示动画。长按300毫秒编辑按钮位置,保持60像素间距,松手保存,正常状态下按钮固定。
  8. // @author jiang
  9. // @match https://x.com/*
  10. // @match https://www.youtube.com/*
  11. // @include *
  12. // @icon https://www.google.com/s2/favicons?sz=64&domain=twitter.com
  13. // @grant GM_getValue
  14. // @grant GM_setValue
  15. // @grant GM_registerMenuCommand
  16. // @grant GM_unregisterMenuCommand
  17. // @license GPL-3.0-only
  18. // ==/UserScript==
  19.  
  20. (function() {
  21. 'use strict';
  22.  
  23. // Prevent duplicate script execution
  24. const key = encodeURIComponent('RefreshAndScroll:执行判断');
  25. if (window[key]) { return; }
  26. window[key] = true;
  27.  
  28. try {
  29. // Twitter (X) refresh interval configuration
  30. let refreshInterval = GM_getValue('refreshInterval', 5);
  31. let menuCommandId = null;
  32.  
  33. // Display a loading spinner animation
  34. function showSpinner() {
  35. const spinner = document.createElement('div');
  36. spinner.id = 'refresh-spinner';
  37. spinner.innerHTML = `
  38. <div class="neon-ring"></div>
  39. <div class="neon-ring inner-ring" style="animation-delay: 0.1s;"></div>
  40. <div class="spark"></div>
  41. <div class="spark" style="animation-delay: 0.2s; transform: rotate(90deg);"></div>
  42. <div class="spark" style="animation-delay: 0.4s; transform: rotate(180deg);"></div>
  43. <div class="spark" style="animation-delay: 0.6s; transform: rotate(270deg);"></div>
  44. `;
  45. spinner.style.cssText = `
  46. position: fixed;
  47. top: 50%;
  48. left: 50%;
  49. transform: translate(-50%, -50%);
  50. width: 100px;
  51. height: 100px;
  52. display: flex;
  53. justify-content: center;
  54. align-items: center;
  55. z-index: 10000;
  56. `;
  57. document.body.appendChild(spinner);
  58. return spinner;
  59. }
  60.  
  61. // Remove the spinner animation
  62. function hideSpinner(spinner) {
  63. if (spinner) spinner.remove();
  64. }
  65.  
  66. // Refresh Twitter (X) timeline
  67. function refreshTimeline() {
  68. if (window.location.href.startsWith('https://x.com/home')) {
  69. const refreshButton = document.querySelector('[href="/home"], [aria-label*="Home"], [data-testid="AppTabBar_Home_Link"]');
  70. if (refreshButton) {
  71. const spinner = showSpinner();
  72. window.scrollTo({ top: 0, behavior: 'smooth' });
  73. refreshButton.click();
  74. setTimeout(() => hideSpinner(spinner), 1500);
  75. } else {
  76. console.log('RefreshAndScroll: Refresh button not found');
  77. }
  78. }
  79. }
  80.  
  81. // Refresh non-Twitter pages (including YouTube)
  82. function refreshPage() {
  83. const spinner = showSpinner();
  84. window.scrollTo({ top: 0, behavior: 'smooth' });
  85. const reloadPromise = new Promise((resolve) => {
  86. window.addEventListener('load', resolve, { once: true });
  87. window.location.reload();
  88. });
  89. reloadPromise.then(() => {
  90. setTimeout(() => hideSpinner(spinner), 100);
  91. });
  92. }
  93.  
  94. // YouTube preloading logic
  95. function preloadYouTubeContent() {
  96. if (!window.location.href.startsWith('https://www.youtube.com/')) return;
  97.  
  98. const preloadThreshold = window.innerHeight * 2; // Two screen heights
  99. let isLoading = false;
  100.  
  101. const scrollHandler = () => {
  102. if (isLoading) return;
  103.  
  104. const scrollPosition = window.scrollY + window.innerHeight;
  105. const pageHeight = document.documentElement.scrollHeight;
  106.  
  107. if (pageHeight - scrollPosition < preloadThreshold) {
  108. isLoading = true;
  109. const lastVideo = document.querySelector('ytd-rich-item-renderer:last-of-type') ||
  110. document.querySelector('ytd-video-renderer:last-of-type') ||
  111. document.querySelector('ytd-grid-video-renderer:last-of-type');
  112. if (lastVideo) {
  113. lastVideo.scrollIntoView({ behavior: 'instant' });
  114. window.dispatchEvent(new Event('scroll'));
  115. setTimeout(() => window.dispatchEvent(new Event('scroll')), 100);
  116. setTimeout(() => window.dispatchEvent(new Event('scroll')), 200);
  117. const observer = new MutationObserver(() => {
  118. isLoading = false;
  119. observer.disconnect();
  120. });
  121. const target = document.querySelector('#contents') || document.body;
  122. observer.observe(target, { childList: true, subtree: true });
  123. setTimeout(() => {
  124. if (isLoading) {
  125. isLoading = false;
  126. observer.disconnect();
  127. }
  128. }, 5000);
  129. } else {
  130. isLoading = false;
  131. }
  132. }
  133. };
  134.  
  135. let isThrottled = false;
  136. window.addEventListener('scroll', () => {
  137. if (!isThrottled) {
  138. isThrottled = true;
  139. scrollHandler();
  140. setTimeout(() => { isThrottled = false; }, 200);
  141. }
  142. });
  143. }
  144.  
  145. // Set custom refresh interval for Twitter (X)
  146. function setCustomInterval() {
  147. const newInterval = prompt("Enter refresh interval in seconds:", refreshInterval);
  148. if (newInterval !== null) {
  149. const parsedInterval = parseInt(newInterval);
  150. if (!isNaN(parsedInterval) && parsedInterval > 0) {
  151. refreshInterval = parsedInterval;
  152. GM_setValue('refreshInterval', refreshInterval);
  153. updateMenuCommand();
  154. } else {
  155. alert("Please enter a valid positive number.");
  156. }
  157. }
  158. }
  159.  
  160. // Update the menu command for refresh interval
  161. function updateMenuCommand() {
  162. if (menuCommandId) {
  163. GM_unregisterMenuCommand(menuCommandId);
  164. }
  165. menuCommandId = GM_registerMenuCommand(`Set Refresh Interval (current: ${refreshInterval}s)`, setCustomInterval);
  166. }
  167.  
  168. // Scroll to top or bottom with conditional refresh
  169. function scrollToPosition(y, isTopButton = false) {
  170. window.scrollTo({ top: y, behavior: 'smooth' });
  171. if (y === 0) {
  172. if (window.location.href.startsWith('https://x.com/home')) {
  173. setTimeout(() => refreshTimeline(), 500);
  174. } else if (isTopButton) {
  175. setTimeout(() => refreshPage(), 500);
  176. }
  177. }
  178. }
  179.  
  180. // Create a visual click effect
  181. function createClickEffect(x, y) {
  182. const effect = document.createElement('div');
  183. effect.style.cssText = `
  184. position: fixed;
  185. left: ${x}px;
  186. top: ${y}px;
  187. width: 10px;
  188. height: 10px;
  189. background: transparent;
  190. border: 2px solid #00ff88;
  191. border-radius: 50%;
  192. pointer-events: none;
  193. z-index: 10000;
  194. animation: shockwave 0.5s ease-out forwards;
  195. box-shadow: 0 0 10px #00ccff, 0 0 20px #00ff88;
  196. `;
  197. document.body.appendChild(effect);
  198. setTimeout(() => effect.remove(), 500);
  199. }
  200.  
  201. // Inject CSS styles for buttons and animations
  202. const style = document.createElement('style');
  203. style.textContent = `
  204. @keyframes pulse {
  205. 0% { transform: scale(1); }
  206. 50% { transform: scale(1.1); }
  207. 100% { transform: scale(1); }
  208. }
  209. @keyframes shockwave {
  210. 0% {
  211. transform: scale(1);
  212. opacity: 1;
  213. border-width: 2px;
  214. }
  215. 100% {
  216. transform: scale(10);
  217. opacity: 0;
  218. border-width: 0;
  219. }
  220. }
  221. @keyframes neonPulse {
  222. 0% {
  223. transform: scale(1) rotate(0deg);
  224. opacity: 1;
  225. box-shadow: 0 0 10px #00ff88, 0 0 20px #00ccff;
  226. }
  227. 50% {
  228. transform: scale(1.2) rotate(180deg);
  229. opacity: 0.8;
  230. box-shadow: 0 0 20px #00ff88, 0 0 40px #00ccff;
  231. }
  232. 100% {
  233. transform: scale(1) rotate(360deg);
  234. opacity: 1;
  235. box-shadow: 0 0 10px #00ff88, 0 0 20px #00ccff;
  236. }
  237. }
  238. @keyframes sparkBurst {
  239. 0% {
  240. transform: translate(0, 0) scale(1);
  241. opacity: 1;
  242. }
  243. 100% {
  244. transform: translate(20px, 20px) scale(0);
  245. opacity: 0;
  246. }
  247. }
  248. .neon-ring {
  249. position: absolute;
  250. width: 80px;
  251. height: 80px;
  252. border: 4px solid transparent;
  253. border-top-color: #00ff88;
  254. border-right-color: #00ccff;
  255. border-radius: 50%;
  256. animation: neonPulse 1.5s linear infinite;
  257. }
  258. .inner-ring {
  259. width: 60px;
  260. height: 60px;
  261. border-top-color: #00ccff;
  262. border-right-color: #00ff88;
  263. animation-direction: reverse;
  264. }
  265. .spark {
  266. position: absolute;
  267. width: 8px;
  268. height: 8px;
  269. background: #ffffff;
  270. border-radius: 50%;
  271. box-shadow: 0 0 10px #00ff88, 0 0 15px #00ccff;
  272. animation: sparkBurst 1.5s ease-out infinite;
  273. }
  274. #sky-scrolltop, #sky-scrolltbtm {
  275. font-family: 'Microsoft YaHei', 'Arial', sans-serif !important;
  276. font-style: normal;
  277. font-weight: 700;
  278. font-size: 16px;
  279. line-height: 48px !important;
  280. text-align: center !important;
  281. background: linear-gradient(135deg, #00ff88, #00ccff) !important;
  282. border-radius: 50% !important;
  283. width: 48px !important;
  284. height: 48px !important;
  285. color: #ffffff !important;
  286. cursor: pointer;
  287. position: fixed;
  288. z-index: 999999;
  289. user-select: none;
  290. box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
  291. transition: transform 0.2s ease, box-shadow 0.2s ease;
  292. animation: pulse 2s infinite;
  293. visibility: visible !important;
  294. display: flex !important;
  295. justify-content: center !important;
  296. align-items: center !important;
  297. }
  298. #sky-scrolltop:hover, #sky-scrolltbtm:hover {
  299. transform: scale(1.15);
  300. box-shadow: 0 6px 16px rgba(0, 0, 0, 0.4);
  301. }
  302. #sky-scrolltop:active, #sky-scrolltbtm:active {
  303. transform: scale(0.95);
  304. }
  305. #sky-scrolltop.editing, #sky-scrolltbtm.editing {
  306. background: linear-gradient(135deg, #ff4444, #ff8888) !important;
  307. animation: none;
  308. }
  309. `;
  310. document.head.appendChild(style);
  311.  
  312. // Create scroll buttons for top and bottom navigation
  313. const scrollTop = document.createElement('div');
  314. scrollTop.id = 'sky-scrolltop';
  315. scrollTop.innerText = '顶';
  316. scrollTop.setAttribute('data-text', '顶');
  317. scrollTop.style.visibility = 'visible';
  318. document.body.appendChild(scrollTop);
  319. console.log('RefreshAndScroll: Created top button with text:', scrollTop.innerText);
  320.  
  321. const scrollBottom = document.createElement('div');
  322. scrollBottom.id = 'sky-scrolltbtm';
  323. scrollBottom.innerText = '底';
  324. scrollBottom.setAttribute('data-text', '底');
  325. scrollBottom.style.visibility = 'visible';
  326. document.body.appendChild(scrollBottom);
  327. console.log('RefreshAndScroll: Created bottom button with text:', scrollBottom.innerText);
  328.  
  329. // Periodically check and restore button text
  330. setInterval(() => {
  331. const topButton = document.querySelector('#sky-scrolltop');
  332. const bottomButton = document.querySelector('#sky-scrolltbtm');
  333. if (topButton && topButton.innerText !== '顶') {
  334. console.error('RefreshAndScroll: Top button text missing, restoring...');
  335. topButton.innerText = topButton.getAttribute('data-text') || '顶';
  336. }
  337. if (bottomButton && bottomButton.innerText !== '底') {
  338. console.error('RefreshAndScroll: Bottom button text missing, restoring...');
  339. bottomButton.innerText = bottomButton.getAttribute('data-text') || '底';
  340. }
  341. }, 1000);
  342.  
  343. // Initialize button positions
  344. let positions = GM_getValue('buttonPositions', { left: '20px', topBottom: '20%', bottomBottom: '12%' });
  345. scrollTop.style.left = positions.left;
  346. scrollTop.style.bottom = positions.topBottom;
  347. scrollBottom.style.left = positions.left;
  348. scrollBottom.style.bottom = positions.bottomBottom;
  349.  
  350. // Position editing logic
  351. let isEditing = false;
  352. let startX, startY, initialLeft, initialBottomTop;
  353. const fixedSpacing = 60;
  354.  
  355. // Start editing button positions on long press
  356. function startEditing(e) {
  357. e.preventDefault();
  358. isEditing = true;
  359. scrollTop.classList.add('editing');
  360. scrollBottom.classList.add('editing');
  361. startX = e.clientX || (e.touches && e.touches[0].clientX);
  362. startY = e.clientY || (e.touches && e.touches[0].clientY);
  363. initialLeft = parseFloat(scrollTop.style.left) || 20;
  364. initialBottomTop = parseFloat(scrollTop.style.bottom) || (window.innerHeight * 0.20);
  365. document.addEventListener('contextmenu', preventDefault, { capture: true });
  366. document.addEventListener('touchstart', preventDefault, { capture: true, passive: false });
  367. }
  368.  
  369. // Move buttons during editing mode
  370. function moveButtons(e) {
  371. if (!isEditing) return;
  372. e.preventDefault();
  373. const clientX = e.clientX || (e.touches && e.touches[0].clientX);
  374. const clientY = e.clientY || (e.touches && e.touches[0].clientY);
  375. if (!clientX || !clientY) return;
  376.  
  377. const deltaX = clientX - startX;
  378. const deltaY = startY - clientY;
  379.  
  380. const buttonWidth = 48;
  381. const buttonHeight = 48;
  382. let newLeft = initialLeft + deltaX;
  383. let newBottomTop = initialBottomTop + deltaY;
  384. let newBottomBtm = newBottomTop - fixedSpacing;
  385.  
  386. newLeft = Math.max(0, Math.min(newLeft, window.innerWidth - buttonWidth));
  387. newBottomTop = Math.max(fixedSpacing, Math.min(newBottomTop, window.innerHeight - buttonHeight));
  388. newBottomBtm = newBottomTop - fixedSpacing;
  389.  
  390. if (newBottomBtm >= 0) {
  391. scrollTop.style.left = `${newLeft}px`;
  392. scrollTop.style.bottom = `${newBottomTop}px`;
  393. scrollBottom.style.left = `${newLeft}px`;
  394. scrollBottom.style.bottom = `${newBottomBtm}px`;
  395. }
  396. }
  397.  
  398. // Stop editing and save positions
  399. function stopEditing(e) {
  400. if (!isEditing) return;
  401. e.preventDefault();
  402. isEditing = false;
  403. scrollTop.classList.remove('editing');
  404. scrollBottom.classList.remove('editing');
  405. positions = {
  406. left: scrollTop.style.left,
  407. topBottom: scrollTop.style.bottom,
  408. bottomBottom: scrollBottom.style.bottom
  409. };
  410. GM_setValue('buttonPositions', positions);
  411. document.removeEventListener('contextmenu', preventDefault, { capture: true });
  412. document.removeEventListener('touchstart', preventDefault, { capture: true });
  413. }
  414.  
  415. // Prevent default browser actions during editing
  416. function preventDefault(e) {
  417. e.preventDefault();
  418. e.stopPropagation();
  419. }
  420.  
  421. // Long-press detection for editing mode
  422. let longPressTimer;
  423. const longPressDuration = 300;
  424.  
  425. function handleLongPressStart(e) {
  426. clearTimeout(longPressTimer);
  427. longPressTimer = setTimeout(() => startEditing(e), longPressDuration);
  428. }
  429.  
  430. function handleLongPressCancel() {
  431. clearTimeout(longPressTimer);
  432. }
  433.  
  434. // Attach event listeners to buttons
  435. scrollTop.addEventListener('mousedown', handleLongPressStart);
  436. scrollTop.addEventListener('touchstart', handleLongPressStart, { passive: false });
  437. scrollTop.addEventListener('mouseup', handleLongPressCancel);
  438. scrollTop.addEventListener('mouseleave', handleLongPressCancel);
  439. scrollTop.addEventListener('touchend', handleLongPressCancel);
  440. scrollTop.addEventListener('click', (e) => {
  441. if (!isEditing) {
  442. const rect = scrollTop.getBoundingClientRect();
  443. createClickEffect(rect.left + rect.width / 2, rect.top + rect.height / 2);
  444. scrollToPosition(0, true);
  445. }
  446. });
  447.  
  448. scrollBottom.addEventListener('mousedown', handleLongPressStart);
  449. scrollBottom.addEventListener('touchstart', handleLongPressStart, { passive: false });
  450. scrollBottom.addEventListener('mouseup', handleLongPressCancel);
  451. scrollBottom.addEventListener('mouseleave', handleLongPressCancel);
  452. scrollBottom.addEventListener('touchend', handleLongPressCancel);
  453. scrollBottom.addEventListener('click', (e) => {
  454. if (!isEditing) {
  455. const rect = scrollBottom.getBoundingClientRect();
  456. createClickEffect(rect.left + rect.width / 2, rect.top + rect.height / 2);
  457. scrollToPosition(document.body.scrollHeight);
  458. }
  459. });
  460.  
  461. // Global event listeners for button movement
  462. document.addEventListener('mousemove', moveButtons);
  463. document.addEventListener('touchmove', moveButtons, { passive: false });
  464. document.addEventListener('mouseup', stopEditing);
  465. document.addEventListener('touchend', stopEditing);
  466.  
  467. // Initialize Twitter (X) menu command
  468. if (window.location.href.startsWith('https://x.com/')) {
  469. updateMenuCommand();
  470. }
  471.  
  472. // Initialize YouTube preloading
  473. preloadYouTubeContent();
  474. } catch (err) {
  475. console.log('RefreshAndScroll:', err);
  476. }
  477. })();