Audio Controls with Auto-Play and Speed Management

Controls audio playback with speed adjustment and auto-play

目前為 2025-04-17 提交的版本,檢視 最新版本

// ==UserScript==
// @name         Audio Controls with Auto-Play and Speed Management
// @namespace    http://tampermonkey.net/
// @version      1.4
// @description  Controls audio playback with speed adjustment and auto-play
// @author       You
// @match        https://inovel12.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

    // 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;

    // 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 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
    const playPauseButton = document.createElement('button');
    playPauseButton.style.cssText = `
        background-color: #4CAF50;
        border: none;
        color: white;
        padding: 8px 12px;
        text-align: center;
        font-size: 14px;
        border-radius: 4px;
        cursor: pointer;
        width: 100%;
    `;
    playPauseButton.textContent = '▶️ Play';
    controlPanel.appendChild(playPauseButton);

    // 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) {
        // 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;

        // Attempt to play the audio
        audioElement.play()
            .then(() => {
                // Success - update UI and reset retry counter
                updatePlayPauseButton();
                countdownDisplay.textContent = '';  // Clear countdown display
                retryAttempts = 0;
            })
            .catch(err => {
                // 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 after multiple attempts';
                    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);
    }

    // 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)}`;
        }

        // 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
    function togglePlayPause() {
        if (!audioElement) return;

        if (audioElement.paused) {
            // Try to play with retry mechanism
            playAudioWithRetry();
        } else {
            audioElement.pause();
            updatePlayPauseButton();
        }
    }

    // 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();
})();