Audio Controls with Auto-Play and Speed Management

Controls audio playback with speed adjustment and auto-play

目前为 2025-04-18 提交的版本,查看 最新版本

  1. // ==UserScript==
  2. // @name Audio Controls with Auto-Play and Speed Management
  3. // @namespace http://tampermonkey.net/
  4. // @version 1.9
  5. // @description Controls audio playback with speed adjustment and auto-play
  6. // @author You
  7. // @match https://inovel1*.com/*
  8. // @grant none
  9. // ==/UserScript==
  10.  
  11. (function() {
  12. 'use strict';
  13. // Configuration
  14. const DEFAULT_PLAYBACK_RATE = 0.7;
  15. const AUTO_PLAY_DELAY = 5000; // 5 seconds
  16. const AUDIO_SELECTOR = 'audio[controls]';
  17. const MAX_RETRY_ATTEMPTS = 5; // Maximum number of retry attempts
  18. const RETRY_DELAY = 1000; // Delay between retries in milliseconds
  19. // Device detection
  20. const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent) && !window.MSStream;
  21. const isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent);
  22. const isIOSSafari = isIOS && isSafari;
  23. // State variables
  24. let audioElement = null;
  25. let playbackRate = parseFloat(localStorage.getItem('audio_playback_rate')) || DEFAULT_PLAYBACK_RATE;
  26. let isMinimized = localStorage.getItem('audio_controls_minimized') === 'true' || false;
  27. let countdownTimer = null;
  28. let retryAttempts = 0;
  29. let hasUserInteracted = false;
  30. // Position settings - load from localStorage or use defaults
  31. let bubblePosition = JSON.parse(localStorage.getItem('audio_bubble_position')) || { top: '20px', left: '20px' };
  32. // Create main container for all controls
  33. const mainContainer = document.createElement('div');
  34. mainContainer.style.cssText = `
  35. position: fixed;
  36. z-index: 9999;
  37. font-family: Arial, sans-serif;
  38. `;
  39. document.body.appendChild(mainContainer);
  40. // Create the expanded control panel
  41. const controlPanel = document.createElement('div');
  42. controlPanel.className = 'audio-control-panel';
  43. controlPanel.style.cssText = `
  44. background-color: rgba(0, 0, 0, 0.7);
  45. padding: 10px;
  46. border-radius: 8px;
  47. display: flex;
  48. flex-direction: column;
  49. gap: 8px;
  50. min-width: 180px;
  51. `;
  52. // Create the minimized bubble view
  53. const bubbleView = document.createElement('div');
  54. bubbleView.className = 'audio-bubble';
  55. bubbleView.style.cssText = `
  56. width: 40px;
  57. height: 40px;
  58. border-radius: 50%;
  59. background-color: rgba(0, 0, 0, 0.7);
  60. display: flex;
  61. justify-content: center;
  62. align-items: center;
  63. cursor: pointer;
  64. user-select: none;
  65. `;
  66. // Create bubble icon
  67. const bubbleIcon = document.createElement('div');
  68. bubbleIcon.style.cssText = `
  69. font-size: 20px;
  70. color: white;
  71. `;
  72. bubbleIcon.innerHTML = '🔊';
  73. bubbleView.appendChild(bubbleIcon);
  74. // Create duration display
  75. const durationDisplay = document.createElement('div');
  76. durationDisplay.style.cssText = `
  77. color: white;
  78. font-size: 14px;
  79. text-align: center;
  80. margin-bottom: 5px;
  81. `;
  82. durationDisplay.textContent = 'Audio Duration: --:--';
  83. controlPanel.appendChild(durationDisplay);
  84. // Create countdown/message display
  85. const countdownDisplay = document.createElement('div');
  86. countdownDisplay.style.cssText = `
  87. color: #ffcc00;
  88. font-size: 14px;
  89. text-align: center;
  90. margin-bottom: 8px;
  91. font-weight: bold;
  92. height: 20px; /* Fixed height to prevent layout shifts */
  93. `;
  94. countdownDisplay.textContent = '';
  95. controlPanel.appendChild(countdownDisplay);
  96. // Create play/pause button with larger size for iOS
  97. const playPauseButton = document.createElement('button');
  98. playPauseButton.style.cssText = `
  99. background-color: #4CAF50;
  100. border: none;
  101. color: white;
  102. padding: ${isIOSSafari ? '12px 15px' : '8px 12px'};
  103. text-align: center;
  104. font-size: ${isIOSSafari ? '16px' : '14px'};
  105. border-radius: 4px;
  106. cursor: pointer;
  107. width: 100%;
  108. font-weight: ${isIOSSafari ? 'bold' : 'normal'};
  109. `;
  110. playPauseButton.textContent = '▶️ Play';
  111. controlPanel.appendChild(playPauseButton);
  112. // Create special instruction for iOS if needed
  113. if (isIOSSafari) {
  114. const iosInstruction = document.createElement('div');
  115. iosInstruction.style.cssText = `
  116. color: #ff9800;
  117. font-size: 12px;
  118. text-align: center;
  119. margin: 5px 0;
  120. font-style: italic;
  121. `;
  122. iosInstruction.textContent = 'Tap Play button to start audio (iOS requires manual activation)';
  123. controlPanel.appendChild(iosInstruction);
  124. }
  125. // Create speed control container
  126. const speedControlContainer = document.createElement('div');
  127. speedControlContainer.style.cssText = `
  128. display: flex;
  129. gap: 8px;
  130. width: 100%;
  131. `;
  132. controlPanel.appendChild(speedControlContainer);
  133. // Create speed down button
  134. const speedDownButton = document.createElement('button');
  135. speedDownButton.style.cssText = `
  136. background-color: #795548;
  137. border: none;
  138. color: white;
  139. padding: 8px 12px;
  140. text-align: center;
  141. font-size: 14px;
  142. border-radius: 4px;
  143. cursor: pointer;
  144. flex: 1;
  145. `;
  146. speedDownButton.textContent = '🐢 Slower';
  147. speedControlContainer.appendChild(speedDownButton);
  148. // Create speed up button
  149. const speedUpButton = document.createElement('button');
  150. speedUpButton.style.cssText = `
  151. background-color: #009688;
  152. border: none;
  153. color: white;
  154. padding: 8px 12px;
  155. text-align: center;
  156. font-size: 14px;
  157. border-radius: 4px;
  158. cursor: pointer;
  159. flex: 1;
  160. `;
  161. speedUpButton.textContent = '🐇 Faster';
  162. speedControlContainer.appendChild(speedUpButton);
  163. // Create speed display
  164. const speedDisplay = document.createElement('div');
  165. speedDisplay.style.cssText = `
  166. color: white;
  167. font-size: 14px;
  168. text-align: center;
  169. margin-top: 5px;
  170. `;
  171. speedDisplay.textContent = `Speed: ${playbackRate.toFixed(1)}x`;
  172. controlPanel.appendChild(speedDisplay);
  173. // Create minimize button
  174. const minimizeButton = document.createElement('button');
  175. minimizeButton.style.cssText = `
  176. background-color: #607D8B;
  177. border: none;
  178. color: white;
  179. padding: 6px 10px;
  180. text-align: center;
  181. font-size: 12px;
  182. border-radius: 4px;
  183. cursor: pointer;
  184. margin-top: 8px;
  185. `;
  186. minimizeButton.textContent = '− Minimize';
  187. controlPanel.appendChild(minimizeButton);
  188. // Function to toggle between expanded and minimized views
  189. function toggleMinimized() {
  190. isMinimized = !isMinimized;
  191. updateViewState();
  192. // Save state to localStorage
  193. localStorage.setItem('audio_controls_minimized', isMinimized);
  194. }
  195. // Function to update the current view based on minimized state
  196. function updateViewState() {
  197. // Clear the container first
  198. while (mainContainer.firstChild) {
  199. mainContainer.removeChild(mainContainer.firstChild);
  200. }
  201. if (isMinimized) {
  202. // Show bubble view
  203. mainContainer.appendChild(bubbleView);
  204. // Set position based on saved values
  205. mainContainer.style.top = bubblePosition.top;
  206. mainContainer.style.left = bubblePosition.left;
  207. mainContainer.style.right = 'auto';
  208. mainContainer.style.bottom = 'auto';
  209. } else {
  210. // Show expanded control panel
  211. mainContainer.appendChild(controlPanel);
  212. // If coming from minimized state, place in the same position
  213. // Otherwise use default bottom right
  214. if (bubblePosition) {
  215. mainContainer.style.top = bubblePosition.top;
  216. mainContainer.style.left = bubblePosition.left;
  217. mainContainer.style.right = 'auto';
  218. mainContainer.style.bottom = 'auto';
  219. } else {
  220. mainContainer.style.top = 'auto';
  221. mainContainer.style.left = 'auto';
  222. mainContainer.style.right = '20px';
  223. mainContainer.style.bottom = '20px';
  224. }
  225. }
  226. }
  227. // Make only the bubble draggable
  228. let isDragging = false;
  229. let dragOffsetX = 0;
  230. let dragOffsetY = 0;
  231. bubbleView.addEventListener('mousedown', function(e) {
  232. // Only initiate drag if user holds for a brief moment
  233. setTimeout(() => {
  234. if (e.buttons === 1) { // Left mouse button
  235. isDragging = true;
  236. dragOffsetX = e.clientX - mainContainer.getBoundingClientRect().left;
  237. dragOffsetY = e.clientY - mainContainer.getBoundingClientRect().top;
  238. bubbleView.style.cursor = 'grabbing';
  239. }
  240. }, 100);
  241. });
  242. document.addEventListener('mousemove', function(e) {
  243. if (!isDragging || !isMinimized) return;
  244. e.preventDefault();
  245. // Calculate new position
  246. const newLeft = e.clientX - dragOffsetX;
  247. const newTop = e.clientY - dragOffsetY;
  248. // Keep within viewport bounds
  249. const maxX = window.innerWidth - bubbleView.offsetWidth;
  250. const maxY = window.innerHeight - bubbleView.offsetHeight;
  251. mainContainer.style.left = `${Math.max(0, Math.min(maxX, newLeft))}px`;
  252. mainContainer.style.top = `${Math.max(0, Math.min(maxY, newTop))}px`;
  253. });
  254. document.addEventListener('mouseup', function(event) {
  255. if (isDragging && isMinimized) {
  256. isDragging = false;
  257. bubbleView.style.cursor = 'pointer';
  258. // Save the position
  259. bubblePosition = {
  260. top: mainContainer.style.top,
  261. left: mainContainer.style.left
  262. };
  263. localStorage.setItem('audio_bubble_position', JSON.stringify(bubblePosition));
  264. // Prevent click if we were dragging
  265. event.preventDefault();
  266. return false;
  267. } else if (isMinimized && (event.target === bubbleView || bubbleView.contains(event.target))) {
  268. // If it was a click (not drag) on the bubble, expand
  269. toggleMinimized();
  270. }
  271. });
  272. // Add touch support for mobile devices - only for bubble
  273. bubbleView.addEventListener('touchstart', function(e) {
  274. const touch = e.touches[0];
  275. isDragging = true;
  276. dragOffsetX = touch.clientX - mainContainer.getBoundingClientRect().left;
  277. dragOffsetY = touch.clientY - mainContainer.getBoundingClientRect().top;
  278. // Prevent scrolling while dragging
  279. e.preventDefault();
  280. });
  281. document.addEventListener('touchmove', function(e) {
  282. if (!isDragging || !isMinimized) return;
  283. const touch = e.touches[0];
  284. // Calculate new position
  285. const newLeft = touch.clientX - dragOffsetX;
  286. const newTop = touch.clientY - dragOffsetY;
  287. // Keep within viewport bounds
  288. const maxX = window.innerWidth - bubbleView.offsetWidth;
  289. const maxY = window.innerHeight - bubbleView.offsetHeight;
  290. mainContainer.style.left = `${Math.max(0, Math.min(maxX, newLeft))}px`;
  291. mainContainer.style.top = `${Math.max(0, Math.min(maxY, newTop))}px`;
  292. // Prevent scrolling while dragging
  293. e.preventDefault();
  294. });
  295. document.addEventListener('touchend', function(event) {
  296. if (isDragging && isMinimized) {
  297. isDragging = false;
  298. // Save the position
  299. bubblePosition = {
  300. top: mainContainer.style.top,
  301. left: mainContainer.style.left
  302. };
  303. localStorage.setItem('audio_bubble_position', JSON.stringify(bubblePosition));
  304. // If touch distance was very small, treat as click
  305. const touchMoved = Math.abs(event.changedTouches[0].clientX - (parseInt(mainContainer.style.left) + dragOffsetX)) > 5 ||
  306. Math.abs(event.changedTouches[0].clientY - (parseInt(mainContainer.style.top) + dragOffsetY)) > 5;
  307. if (!touchMoved && (event.target === bubbleView || bubbleView.contains(event.target))) {
  308. toggleMinimized();
  309. }
  310. }
  311. });
  312. // Add click event for minimize button
  313. minimizeButton.addEventListener('click', toggleMinimized);
  314. // Function to find audio element
  315. function findAudioElement() {
  316. const audio = document.querySelector(AUDIO_SELECTOR);
  317. if (audio && audio !== audioElement) {
  318. audioElement = audio;
  319. initializeAudio();
  320. }
  321. if (!audioElement) {
  322. // If not found, try again in 500ms
  323. setTimeout(findAudioElement, 500);
  324. }
  325. }
  326. // Function to format time in MM:SS
  327. function formatTime(seconds) {
  328. const minutes = Math.floor(seconds / 60);
  329. const secs = Math.floor(seconds % 60);
  330. return `${minutes}:${secs.toString().padStart(2, '0')}`;
  331. }
  332. // Function to run the countdown timer
  333. function startCountdown(seconds) {
  334. // Skip countdown for iOS Safari since we need user interaction
  335. if (isIOSSafari) {
  336. countdownDisplay.textContent = 'Tap Play button to start audio';
  337. return;
  338. }
  339. // Clear any existing countdown
  340. if (countdownTimer) {
  341. clearInterval(countdownTimer);
  342. }
  343. let remainingSeconds = seconds;
  344. updateCountdownDisplay(remainingSeconds);
  345. countdownTimer = setInterval(() => {
  346. remainingSeconds--;
  347. updateCountdownDisplay(remainingSeconds);
  348. if (remainingSeconds <= 0) {
  349. clearInterval(countdownTimer);
  350. countdownTimer = null;
  351. // Play the audio when countdown reaches zero
  352. if (audioElement) {
  353. // Reset retry counter before attempting to play
  354. retryAttempts = 0;
  355. playAudioWithRetry();
  356. }
  357. }
  358. }, 1000);
  359. }
  360. // Function to update countdown display
  361. function updateCountdownDisplay(seconds) {
  362. countdownDisplay.textContent = `Auto-play in ${seconds} seconds`;
  363. }
  364. // Function to play audio with retry mechanism
  365. function playAudioWithRetry() {
  366. if (!audioElement) return;
  367. // For iOS Safari, we need direct user interaction - don't auto-retry
  368. if (isIOSSafari && !hasUserInteracted) {
  369. countdownDisplay.textContent = 'Tap Play button to start audio';
  370. return;
  371. }
  372. // For iOS Safari, try to load() before play() to ensure content is ready
  373. if (isIOSSafari) {
  374. audioElement.load();
  375. }
  376. // Attempt to play the audio
  377. audioElement.play()
  378. .then(() => {
  379. // Success - update UI and reset retry counter
  380. updatePlayPauseButton();
  381. countdownDisplay.textContent = ''; // Clear countdown display
  382. retryAttempts = 0;
  383. hasUserInteracted = true;
  384. })
  385. .catch(err => {
  386. console.log('Audio play error:', err);
  387. // For iOS Safari, we need to wait for user interaction
  388. if (isIOSSafari) {
  389. countdownDisplay.textContent = 'Tap Play button to start audio';
  390. return;
  391. }
  392. // Error playing audio - retry if under max attempts
  393. retryAttempts++;
  394. if (retryAttempts <= MAX_RETRY_ATTEMPTS) {
  395. // Update the countdown display with retry information
  396. countdownDisplay.textContent = `Auto-play blocked. Retrying (${retryAttempts}/${MAX_RETRY_ATTEMPTS})...`;
  397. // Try to play again after a delay
  398. setTimeout(() => {
  399. togglePlayPause(); // This will call play() again
  400. }, RETRY_DELAY);
  401. } else {
  402. // Max retries reached
  403. countdownDisplay.textContent = 'Auto-play failed. Tap Play button to start.';
  404. retryAttempts = 0;
  405. }
  406. });
  407. }
  408. // Function to initialize audio controls
  409. function initializeAudio() {
  410. if (!audioElement) return;
  411. // Set saved playback rate
  412. audioElement.playbackRate = playbackRate;
  413. // Update UI based on current state
  414. updatePlayPauseButton();
  415. // Get duration when metadata is loaded and start countdown
  416. if (audioElement.readyState >= 1) {
  417. handleAudioLoaded();
  418. } else {
  419. audioElement.addEventListener('loadedmetadata', handleAudioLoaded);
  420. }
  421. // Add event listeners
  422. audioElement.addEventListener('play', updatePlayPauseButton);
  423. audioElement.addEventListener('pause', updatePlayPauseButton);
  424. audioElement.addEventListener('ended', updatePlayPauseButton);
  425. // iOS-specific: preload audio when possible
  426. if (isIOSSafari) {
  427. audioElement.preload = 'auto';
  428. audioElement.load();
  429. }
  430. }
  431. // Function to handle audio loaded event
  432. function handleAudioLoaded() {
  433. if (!audioElement) return;
  434. // Update duration display
  435. if (!isNaN(audioElement.duration)) {
  436. durationDisplay.textContent = `Audio Duration: ${formatTime(audioElement.duration)}`;
  437. }
  438. // For iOS Safari, don't start countdown - wait for user interaction
  439. if (isIOSSafari) {
  440. countdownDisplay.textContent = 'Tap Play button to start audio';
  441. return;
  442. }
  443. // Start countdown for auto-play (5 seconds)
  444. const countdownSeconds = Math.floor(AUTO_PLAY_DELAY / 1000);
  445. startCountdown(countdownSeconds);
  446. }
  447. // Function to update play/pause button state
  448. function updatePlayPauseButton() {
  449. if (!audioElement) return;
  450. if (audioElement.paused) {
  451. playPauseButton.textContent = '▶️ Play';
  452. playPauseButton.style.backgroundColor = '#4CAF50';
  453. } else {
  454. playPauseButton.textContent = '⏸️ Pause';
  455. playPauseButton.style.backgroundColor = '#F44336';
  456. // If playing, clear countdown
  457. if (countdownTimer) {
  458. clearInterval(countdownTimer);
  459. countdownTimer = null;
  460. countdownDisplay.textContent = '';
  461. }
  462. }
  463. }
  464. // Function to toggle play/pause - optimized for iOS
  465. function togglePlayPause() {
  466. if (!audioElement) return;
  467. // Set flag for user interaction (important for iOS)
  468. hasUserInteracted = true;
  469. if (audioElement.paused) {
  470. // For iOS Safari, need to try additional methods
  471. if (isIOSSafari) {
  472. // Make sure audio is loaded
  473. audioElement.load();
  474. // For iOS, try to unlock audio context if possible
  475. unlockAudioContext();
  476. // Try to play with normal method
  477. audioElement.play()
  478. .then(() => {
  479. updatePlayPauseButton();
  480. countdownDisplay.textContent = '';
  481. })
  482. .catch(err => {
  483. console.log('iOS play error:', err);
  484. countdownDisplay.textContent = 'Playback error. Try again.';
  485. });
  486. } else {
  487. // Normal browsers - try to play with retry mechanism
  488. playAudioWithRetry();
  489. }
  490. } else {
  491. audioElement.pause();
  492. updatePlayPauseButton();
  493. }
  494. }
  495. // Special function to try to unlock audio context on iOS
  496. function unlockAudioContext() {
  497. // Create a silent audio buffer
  498. try {
  499. const AudioContext = window.AudioContext || window.webkitAudioContext;
  500. if (!AudioContext) return;
  501. const audioCtx = new AudioContext();
  502. const buffer = audioCtx.createBuffer(1, 1, 22050);
  503. const source = audioCtx.createBufferSource();
  504. source.buffer = buffer;
  505. source.connect(audioCtx.destination);
  506. source.start(0);
  507. // Resume audio context if suspended
  508. if (audioCtx.state === 'suspended') {
  509. audioCtx.resume();
  510. }
  511. } catch (e) {
  512. console.log('Audio context unlock error:', e);
  513. }
  514. }
  515. // Function to update playback speed
  516. function updatePlaybackSpeed(newRate) {
  517. playbackRate = newRate;
  518. // Update audio element if exists
  519. if (audioElement) {
  520. audioElement.playbackRate = playbackRate;
  521. }
  522. // Update display
  523. speedDisplay.textContent = `Speed: ${playbackRate.toFixed(1)}x`;
  524. // Save to localStorage
  525. localStorage.setItem('audio_playback_rate', playbackRate);
  526. }
  527. // Function to decrease playback speed
  528. function decreaseSpeed() {
  529. const newRate = Math.max(0.5, playbackRate - 0.1);
  530. updatePlaybackSpeed(newRate);
  531. }
  532. // Function to increase playback speed
  533. function increaseSpeed() {
  534. const newRate = Math.min(2.5, playbackRate + 0.1);
  535. updatePlaybackSpeed(newRate);
  536. }
  537. // Set up event listeners for buttons
  538. playPauseButton.addEventListener('click', togglePlayPause);
  539. speedDownButton.addEventListener('click', decreaseSpeed);
  540. speedUpButton.addEventListener('click', increaseSpeed);
  541. // Create an observer to watch for new audio elements
  542. const audioObserver = new MutationObserver(function(mutations) {
  543. mutations.forEach(function(mutation) {
  544. if (mutation.addedNodes.length) {
  545. mutation.addedNodes.forEach(function(node) {
  546. if (node.nodeName === 'AUDIO' ||
  547. (node.nodeType === 1 && node.querySelector(AUDIO_SELECTOR))) {
  548. // Reset audio element and reinitialize
  549. audioElement = null;
  550. findAudioElement();
  551. }
  552. });
  553. }
  554. });
  555. });
  556. // Start observing the document
  557. audioObserver.observe(document.body, { childList: true, subtree: true });
  558. // Initialize the view state
  559. updateViewState();
  560. // Start finding audio element
  561. findAudioElement();
  562. })();