Audio Controls with Auto-Play and Speed Management

Controls audio playback with speed adjustment and auto-play

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

// ==UserScript==
// @name         Audio Controls with Auto-Play and Speed Management
// @namespace    http://tampermonkey.net/
// @version      1.9
// @description  Controls audio playback with speed adjustment and auto-play
// @author       You
// @match        https://inovel1*.com/*
// @grant        none
// ==/UserScript==

(function() {
    'use strict';
    
    // Configuration
    const DEFAULT_PLAYBACK_RATE = 0.7;
    const AUTO_PLAY_DELAY = 5000; // 5 seconds
    const AUDIO_SELECTOR = 'audio[controls]';
    const MAX_RETRY_ATTEMPTS = 5; // Maximum number of retry attempts
    const RETRY_DELAY = 1000; // Delay between retries in milliseconds
    
    // Device detection
    const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent) && !window.MSStream;
    const isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent);
    const isIOSSafari = isIOS && isSafari;
    
    // State variables
    let audioElement = null;
    let playbackRate = parseFloat(localStorage.getItem('audio_playback_rate')) || DEFAULT_PLAYBACK_RATE;
    let isMinimized = localStorage.getItem('audio_controls_minimized') === 'true' || false;
    let countdownTimer = null;
    let retryAttempts = 0;
    let hasUserInteracted = false;
    
    // Position settings - load from localStorage or use defaults
    let bubblePosition = JSON.parse(localStorage.getItem('audio_bubble_position')) || { top: '20px', left: '20px' };
    
    // Create main container for all controls
    const mainContainer = document.createElement('div');
    mainContainer.style.cssText = `
        position: fixed;
        z-index: 9999;
        font-family: Arial, sans-serif;
    `;
    document.body.appendChild(mainContainer);
    
    // Create the expanded control panel
    const controlPanel = document.createElement('div');
    controlPanel.className = 'audio-control-panel';
    controlPanel.style.cssText = `
        background-color: rgba(0, 0, 0, 0.7);
        padding: 10px;
        border-radius: 8px;
        display: flex;
        flex-direction: column;
        gap: 8px;
        min-width: 180px;
    `;
    
    // Create the minimized bubble view
    const bubbleView = document.createElement('div');
    bubbleView.className = 'audio-bubble';
    bubbleView.style.cssText = `
        width: 40px;
        height: 40px;
        border-radius: 50%;
        background-color: rgba(0, 0, 0, 0.7);
        display: flex;
        justify-content: center;
        align-items: center;
        cursor: pointer;
        user-select: none;
    `;
    
    // Create bubble icon
    const bubbleIcon = document.createElement('div');
    bubbleIcon.style.cssText = `
        font-size: 20px;
        color: white;
    `;
    bubbleIcon.innerHTML = '🔊';
    bubbleView.appendChild(bubbleIcon);
    
    // Create duration display
    const durationDisplay = document.createElement('div');
    durationDisplay.style.cssText = `
        color: white;
        font-size: 14px;
        text-align: center;
        margin-bottom: 5px;
    `;
    durationDisplay.textContent = 'Audio Duration: --:--';
    controlPanel.appendChild(durationDisplay);
    
    // Create countdown/message display
    const countdownDisplay = document.createElement('div');
    countdownDisplay.style.cssText = `
        color: #ffcc00;
        font-size: 14px;
        text-align: center;
        margin-bottom: 8px;
        font-weight: bold;
        height: 20px; /* Fixed height to prevent layout shifts */
    `;
    countdownDisplay.textContent = '';
    controlPanel.appendChild(countdownDisplay);
    
    // Create play/pause button with larger size for iOS
    const playPauseButton = document.createElement('button');
    playPauseButton.style.cssText = `
        background-color: #4CAF50;
        border: none;
        color: white;
        padding: ${isIOSSafari ? '12px 15px' : '8px 12px'};
        text-align: center;
        font-size: ${isIOSSafari ? '16px' : '14px'};
        border-radius: 4px;
        cursor: pointer;
        width: 100%;
        font-weight: ${isIOSSafari ? 'bold' : 'normal'};
    `;
    playPauseButton.textContent = '▶️ Play';
    controlPanel.appendChild(playPauseButton);
    
    // Create special instruction for iOS if needed
    if (isIOSSafari) {
        const iosInstruction = document.createElement('div');
        iosInstruction.style.cssText = `
            color: #ff9800;
            font-size: 12px;
            text-align: center;
            margin: 5px 0;
            font-style: italic;
        `;
        iosInstruction.textContent = 'Tap Play button to start audio (iOS requires manual activation)';
        controlPanel.appendChild(iosInstruction);
    }
    
    // Create speed control container
    const speedControlContainer = document.createElement('div');
    speedControlContainer.style.cssText = `
        display: flex;
        gap: 8px;
        width: 100%;
    `;
    controlPanel.appendChild(speedControlContainer);
    
    // Create speed down button
    const speedDownButton = document.createElement('button');
    speedDownButton.style.cssText = `
        background-color: #795548;
        border: none;
        color: white;
        padding: 8px 12px;
        text-align: center;
        font-size: 14px;
        border-radius: 4px;
        cursor: pointer;
        flex: 1;
    `;
    speedDownButton.textContent = '🐢 Slower';
    speedControlContainer.appendChild(speedDownButton);
    
    // Create speed up button
    const speedUpButton = document.createElement('button');
    speedUpButton.style.cssText = `
        background-color: #009688;
        border: none;
        color: white;
        padding: 8px 12px;
        text-align: center;
        font-size: 14px;
        border-radius: 4px;
        cursor: pointer;
        flex: 1;
    `;
    speedUpButton.textContent = '🐇 Faster';
    speedControlContainer.appendChild(speedUpButton);
    
    // Create speed display
    const speedDisplay = document.createElement('div');
    speedDisplay.style.cssText = `
        color: white;
        font-size: 14px;
        text-align: center;
        margin-top: 5px;
    `;
    speedDisplay.textContent = `Speed: ${playbackRate.toFixed(1)}x`;
    controlPanel.appendChild(speedDisplay);
    
    // Create minimize button
    const minimizeButton = document.createElement('button');
    minimizeButton.style.cssText = `
        background-color: #607D8B;
        border: none;
        color: white;
        padding: 6px 10px;
        text-align: center;
        font-size: 12px;
        border-radius: 4px;
        cursor: pointer;
        margin-top: 8px;
    `;
    minimizeButton.textContent = '− Minimize';
    controlPanel.appendChild(minimizeButton);
    
    // Function to toggle between expanded and minimized views
    function toggleMinimized() {
        isMinimized = !isMinimized;
        updateViewState();
        
        // Save state to localStorage
        localStorage.setItem('audio_controls_minimized', isMinimized);
    }
    
    // Function to update the current view based on minimized state
    function updateViewState() {
        // Clear the container first
        while (mainContainer.firstChild) {
            mainContainer.removeChild(mainContainer.firstChild);
        }
        
        if (isMinimized) {
            // Show bubble view
            mainContainer.appendChild(bubbleView);
            
            // Set position based on saved values
            mainContainer.style.top = bubblePosition.top;
            mainContainer.style.left = bubblePosition.left;
            mainContainer.style.right = 'auto';
            mainContainer.style.bottom = 'auto';
        } else {
            // Show expanded control panel
            mainContainer.appendChild(controlPanel);
            
            // If coming from minimized state, place in the same position
            // Otherwise use default bottom right
            if (bubblePosition) {
                mainContainer.style.top = bubblePosition.top;
                mainContainer.style.left = bubblePosition.left;
                mainContainer.style.right = 'auto';
                mainContainer.style.bottom = 'auto';
            } else {
                mainContainer.style.top = 'auto';
                mainContainer.style.left = 'auto';
                mainContainer.style.right = '20px';
                mainContainer.style.bottom = '20px';
            }
        }
    }
    
    // Make only the bubble draggable
    let isDragging = false;
    let dragOffsetX = 0;
    let dragOffsetY = 0;
    
    bubbleView.addEventListener('mousedown', function(e) {
        // Only initiate drag if user holds for a brief moment
        setTimeout(() => {
            if (e.buttons === 1) { // Left mouse button
                isDragging = true;
                dragOffsetX = e.clientX - mainContainer.getBoundingClientRect().left;
                dragOffsetY = e.clientY - mainContainer.getBoundingClientRect().top;
                bubbleView.style.cursor = 'grabbing';
            }
        }, 100);
    });
    
    document.addEventListener('mousemove', function(e) {
        if (!isDragging || !isMinimized) return;
        
        e.preventDefault();
        
        // Calculate new position
        const newLeft = e.clientX - dragOffsetX;
        const newTop = e.clientY - dragOffsetY;
        
        // Keep within viewport bounds
        const maxX = window.innerWidth - bubbleView.offsetWidth;
        const maxY = window.innerHeight - bubbleView.offsetHeight;
        
        mainContainer.style.left = `${Math.max(0, Math.min(maxX, newLeft))}px`;
        mainContainer.style.top = `${Math.max(0, Math.min(maxY, newTop))}px`;
    });
    
    document.addEventListener('mouseup', function(event) {
        if (isDragging && isMinimized) {
            isDragging = false;
            bubbleView.style.cursor = 'pointer';
            
            // Save the position
            bubblePosition = {
                top: mainContainer.style.top,
                left: mainContainer.style.left
            };
            localStorage.setItem('audio_bubble_position', JSON.stringify(bubblePosition));
            
            // Prevent click if we were dragging
            event.preventDefault();
            return false;
        } else if (isMinimized && (event.target === bubbleView || bubbleView.contains(event.target))) {
            // If it was a click (not drag) on the bubble, expand
            toggleMinimized();
        }
    });
    
    // Add touch support for mobile devices - only for bubble
    bubbleView.addEventListener('touchstart', function(e) {
        const touch = e.touches[0];
        isDragging = true;
        dragOffsetX = touch.clientX - mainContainer.getBoundingClientRect().left;
        dragOffsetY = touch.clientY - mainContainer.getBoundingClientRect().top;
        
        // Prevent scrolling while dragging
        e.preventDefault();
    });
    
    document.addEventListener('touchmove', function(e) {
        if (!isDragging || !isMinimized) return;
        
        const touch = e.touches[0];
        
        // Calculate new position
        const newLeft = touch.clientX - dragOffsetX;
        const newTop = touch.clientY - dragOffsetY;
        
        // Keep within viewport bounds
        const maxX = window.innerWidth - bubbleView.offsetWidth;
        const maxY = window.innerHeight - bubbleView.offsetHeight;
        
        mainContainer.style.left = `${Math.max(0, Math.min(maxX, newLeft))}px`;
        mainContainer.style.top = `${Math.max(0, Math.min(maxY, newTop))}px`;
        
        // Prevent scrolling while dragging
        e.preventDefault();
    });
    
    document.addEventListener('touchend', function(event) {
        if (isDragging && isMinimized) {
            isDragging = false;
            
            // Save the position
            bubblePosition = {
                top: mainContainer.style.top,
                left: mainContainer.style.left
            };
            localStorage.setItem('audio_bubble_position', JSON.stringify(bubblePosition));
            
            // If touch distance was very small, treat as click
            const touchMoved = Math.abs(event.changedTouches[0].clientX - (parseInt(mainContainer.style.left) + dragOffsetX)) > 5 ||
                              Math.abs(event.changedTouches[0].clientY - (parseInt(mainContainer.style.top) + dragOffsetY)) > 5;
            
            if (!touchMoved && (event.target === bubbleView || bubbleView.contains(event.target))) {
                toggleMinimized();
            }
        }
    });
    
    // Add click event for minimize button
    minimizeButton.addEventListener('click', toggleMinimized);
    
    // Function to find audio element
    function findAudioElement() {
        const audio = document.querySelector(AUDIO_SELECTOR);
        if (audio && audio !== audioElement) {
            audioElement = audio;
            initializeAudio();
        }
        
        if (!audioElement) {
            // If not found, try again in 500ms
            setTimeout(findAudioElement, 500);
        }
    }
    
    // Function to format time in MM:SS
    function formatTime(seconds) {
        const minutes = Math.floor(seconds / 60);
        const secs = Math.floor(seconds % 60);
        return `${minutes}:${secs.toString().padStart(2, '0')}`;
    }
    
    // Function to run the countdown timer
    function startCountdown(seconds) {
        // Skip countdown for iOS Safari since we need user interaction
        if (isIOSSafari) {
            countdownDisplay.textContent = 'Tap Play button to start audio';
            return;
        }
        
        // Clear any existing countdown
        if (countdownTimer) {
            clearInterval(countdownTimer);
        }
        
        let remainingSeconds = seconds;
        updateCountdownDisplay(remainingSeconds);
        
        countdownTimer = setInterval(() => {
            remainingSeconds--;
            updateCountdownDisplay(remainingSeconds);
            
            if (remainingSeconds <= 0) {
                clearInterval(countdownTimer);
                countdownTimer = null;
                
                // Play the audio when countdown reaches zero
                if (audioElement) {
                    // Reset retry counter before attempting to play
                    retryAttempts = 0;
                    playAudioWithRetry();
                }
            }
        }, 1000);
    }
    
    // Function to update countdown display
    function updateCountdownDisplay(seconds) {
        countdownDisplay.textContent = `Auto-play in ${seconds} seconds`;
    }
    
    // Function to play audio with retry mechanism
    function playAudioWithRetry() {
        if (!audioElement) return;
        
        // For iOS Safari, we need direct user interaction - don't auto-retry
        if (isIOSSafari && !hasUserInteracted) {
            countdownDisplay.textContent = 'Tap Play button to start audio';
            return;
        }
        
        // For iOS Safari, try to load() before play() to ensure content is ready
        if (isIOSSafari) {
            audioElement.load();
        }
        
        // Attempt to play the audio
        audioElement.play()
            .then(() => {
                // Success - update UI and reset retry counter
                updatePlayPauseButton();
                countdownDisplay.textContent = '';  // Clear countdown display
                retryAttempts = 0;
                hasUserInteracted = true;
            })
            .catch(err => {
                console.log('Audio play error:', err);
                
                // For iOS Safari, we need to wait for user interaction
                if (isIOSSafari) {
                    countdownDisplay.textContent = 'Tap Play button to start audio';
                    return;
                }
                
                // Error playing audio - retry if under max attempts
                retryAttempts++;
                
                if (retryAttempts <= MAX_RETRY_ATTEMPTS) {
                    // Update the countdown display with retry information
                    countdownDisplay.textContent = `Auto-play blocked. Retrying (${retryAttempts}/${MAX_RETRY_ATTEMPTS})...`;
                    
                    // Try to play again after a delay
                    setTimeout(() => {
                        togglePlayPause();  // This will call play() again
                    }, RETRY_DELAY);
                } else {
                    // Max retries reached
                    countdownDisplay.textContent = 'Auto-play failed. Tap Play button to start.';
                    retryAttempts = 0;
                }
            });
    }
    
    // Function to initialize audio controls
    function initializeAudio() {
        if (!audioElement) return;
        
        // Set saved playback rate
        audioElement.playbackRate = playbackRate;
        
        // Update UI based on current state
        updatePlayPauseButton();
        
        // Get duration when metadata is loaded and start countdown
        if (audioElement.readyState >= 1) {
            handleAudioLoaded();
        } else {
            audioElement.addEventListener('loadedmetadata', handleAudioLoaded);
        }
        
        // Add event listeners
        audioElement.addEventListener('play', updatePlayPauseButton);
        audioElement.addEventListener('pause', updatePlayPauseButton);
        audioElement.addEventListener('ended', updatePlayPauseButton);
        
        // iOS-specific: preload audio when possible
        if (isIOSSafari) {
            audioElement.preload = 'auto';
            audioElement.load();
        }
    }
    
    // Function to handle audio loaded event
    function handleAudioLoaded() {
        if (!audioElement) return;
        
        // Update duration display
        if (!isNaN(audioElement.duration)) {
            durationDisplay.textContent = `Audio Duration: ${formatTime(audioElement.duration)}`;
        }
        
        // For iOS Safari, don't start countdown - wait for user interaction
        if (isIOSSafari) {
            countdownDisplay.textContent = 'Tap Play button to start audio';
            return;
        }
        
        // Start countdown for auto-play (5 seconds)
        const countdownSeconds = Math.floor(AUTO_PLAY_DELAY / 1000);
        startCountdown(countdownSeconds);
    }
    
    // Function to update play/pause button state
    function updatePlayPauseButton() {
        if (!audioElement) return;
        
        if (audioElement.paused) {
            playPauseButton.textContent = '▶️ Play';
            playPauseButton.style.backgroundColor = '#4CAF50';
        } else {
            playPauseButton.textContent = '⏸️ Pause';
            playPauseButton.style.backgroundColor = '#F44336';
            
            // If playing, clear countdown
            if (countdownTimer) {
                clearInterval(countdownTimer);
                countdownTimer = null;
                countdownDisplay.textContent = '';
            }
        }
    }
    
    // Function to toggle play/pause - optimized for iOS
    function togglePlayPause() {
        if (!audioElement) return;
        
        // Set flag for user interaction (important for iOS)
        hasUserInteracted = true;
        
        if (audioElement.paused) {
            // For iOS Safari, need to try additional methods
            if (isIOSSafari) {
                // Make sure audio is loaded
                audioElement.load();
                
                // For iOS, try to unlock audio context if possible
                unlockAudioContext();
                
                // Try to play with normal method
                audioElement.play()
                    .then(() => {
                        updatePlayPauseButton();
                        countdownDisplay.textContent = '';
                    })
                    .catch(err => {
                        console.log('iOS play error:', err);
                        countdownDisplay.textContent = 'Playback error. Try again.';
                    });
            } else {
                // Normal browsers - try to play with retry mechanism
                playAudioWithRetry();
            }
        } else {
            audioElement.pause();
            updatePlayPauseButton();
        }
    }
    
    // Special function to try to unlock audio context on iOS
    function unlockAudioContext() {
        // Create a silent audio buffer
        try {
            const AudioContext = window.AudioContext || window.webkitAudioContext;
            if (!AudioContext) return;
            
            const audioCtx = new AudioContext();
            const buffer = audioCtx.createBuffer(1, 1, 22050);
            const source = audioCtx.createBufferSource();
            source.buffer = buffer;
            source.connect(audioCtx.destination);
            source.start(0);
            
            // Resume audio context if suspended
            if (audioCtx.state === 'suspended') {
                audioCtx.resume();
            }
        } catch (e) {
            console.log('Audio context unlock error:', e);
        }
    }
    
    // Function to update playback speed
    function updatePlaybackSpeed(newRate) {
        playbackRate = newRate;
        
        // Update audio element if exists
        if (audioElement) {
            audioElement.playbackRate = playbackRate;
        }
        
        // Update display
        speedDisplay.textContent = `Speed: ${playbackRate.toFixed(1)}x`;
        
        // Save to localStorage
        localStorage.setItem('audio_playback_rate', playbackRate);
    }
    
    // Function to decrease playback speed
    function decreaseSpeed() {
        const newRate = Math.max(0.5, playbackRate - 0.1);
        updatePlaybackSpeed(newRate);
    }
    
    // Function to increase playback speed
    function increaseSpeed() {
        const newRate = Math.min(2.5, playbackRate + 0.1);
        updatePlaybackSpeed(newRate);
    }
    
    // Set up event listeners for buttons
    playPauseButton.addEventListener('click', togglePlayPause);
    speedDownButton.addEventListener('click', decreaseSpeed);
    speedUpButton.addEventListener('click', increaseSpeed);
    
    // Create an observer to watch for new audio elements
    const audioObserver = new MutationObserver(function(mutations) {
        mutations.forEach(function(mutation) {
            if (mutation.addedNodes.length) {
                mutation.addedNodes.forEach(function(node) {
                    if (node.nodeName === 'AUDIO' || 
                        (node.nodeType === 1 && node.querySelector(AUDIO_SELECTOR))) {
                        // Reset audio element and reinitialize
                        audioElement = null;
                        findAudioElement();
                    }
                });
            }
        });
    });
    
    // Start observing the document
    audioObserver.observe(document.body, { childList: true, subtree: true });
    
    // Initialize the view state
    updateViewState();
    
    // Start finding audio element
    findAudioElement();
})();