Instagram Video Controls - Enhanced State Management

Video controls for Instagram with persistent state across videos

目前为 2025-01-25 提交的版本。查看 最新版本

// ==UserScript==
// @name         Instagram Video Controls - Enhanced State Management
// @namespace    http://tampermonkey.net/
// @version      2.5
// @description  Video controls for Instagram with persistent state across videos
// @match        https://www.instagram.com/*
// @grant        none
// ==/UserScript==

(function() {
    'use strict';

    // Prevent automatic video muting
    const createElementOriginal = document.createElement;
    document.createElement = function(tag) {
        const element = createElementOriginal.call(document, tag);
        if (tag.toLowerCase() === 'video') {
            Object.defineProperty(element, 'muted', {
                configurable: false,
                get: function() { return false; },
                set: function(value) { return false; }
            });
        }
        return element;
    }

    // Enhanced State management with global event handling
    const VideoState = {
        preferences: {
            volume: 1,
            speed: 1,
            backgroundPlay: false,
            isMuted: false
        },
        activeControlInstances: new Set(),

        initialize() {
            try {
                const saved = JSON.parse(localStorage.getItem('igVideoPreferences'));
                if (saved) {
                    this.preferences = { ...this.preferences, ...saved };
                }
                window.addEventListener('igVideoStateChange', this.broadcastStateChange.bind(this));
            } catch (e) {
                console.error('Error loading preferences:', e);
            }
        },

        save() {
            try {
                localStorage.setItem('igVideoPreferences', JSON.stringify(this.preferences));
                window.dispatchEvent(new CustomEvent('igVideoStateChange', {
                    detail: { ...this.preferences }
                }));
            } catch (e) {
                console.error('Error saving preferences:', e);
            }
        },

        update(key, value) {
            this.preferences[key] = value;
            this.save();
        },

        registerInstance(controlInstance) {
            this.activeControlInstances.add(controlInstance);
        },

        unregisterInstance(controlInstance) {
            this.activeControlInstances.delete(controlInstance);
        },

        broadcastStateChange(event) {
            const newState = event.detail;
            this.activeControlInstances.forEach(instance => {
                instance.applyState(newState);
            });
        },

        getInitialState(video) {
            return {
                volume: video.volume,
                speed: video.playbackRate,
                isMuted: video.muted
            };
        }
    };

    // UI Constants
    const UI = {
        colors: {
            primary: '#0095f6',
            background: 'rgba(0,0,0,0.8)',
            text: '#ffffff',
            hover: 'rgba(255,255,255,0.1)'
        },
        sizes: {
            buttonSize: '32px',
            fontSize: {
                normal: '14px',
                large: '20px'
            },
            controlHeight: '48px',
            timelineHeight: '3px',
            timelineActiveHeight: '5px'
        },
        styles: {
            button: {
                background: 'none',
                border: 'none',
                color: '#ffffff',
                cursor: 'pointer',
                padding: '0',
                display: 'flex',
                alignItems: 'center',
                justifyContent: 'center',
                transition: 'opacity 0.2s'
            },
            container: {
                display: 'flex',
                position: 'relative'
            }
        }
    };

    // Utility functions for DOM manipulation and event handling
    const DOMUtils = {
        createButton(options = {}) {
            const button = document.createElement('button');
            button.className = options.className || '';
            Object.assign(button.style, {
                ...UI.styles.button,
                ...options.style
            });
            if (options.innerHTML) button.innerHTML = options.innerHTML;
            if (options.onclick) button.addEventListener('click', options.onclick);
            return button;
        },

        createContainer(options = {}) {
            const container = document.createElement('div');
            container.className = options.className || '';
            Object.assign(container.style, {
                ...UI.styles.container,
                ...options.style
            });
            return container;
        },

        setupHoverMenu(control, menuContainer, delay = 500) {
            let timeout;

            const showMenu = () => {
                clearTimeout(timeout);
                menuContainer.style.display = menuContainer.dataset.displayType || 'block';
            };

            const hideMenu = () => {
                timeout = setTimeout(() => {
                    menuContainer.style.display = 'none';
                }, delay);
            };

            control.addEventListener('mouseenter', showMenu);
            control.addEventListener('mouseleave', hideMenu);
            menuContainer.addEventListener('mouseenter', () => clearTimeout(timeout));
            menuContainer.addEventListener('mouseleave', hideMenu);

            return { showMenu, hideMenu };
        },

        formatTime(seconds) {
            const mins = Math.floor(seconds / 60);
            const secs = Math.floor(seconds % 60);
            return `${mins}:${String(secs).padStart(2, '0')}`;
        }
    };

    class VideoControls {
        constructor(videoElement) {
            this.video = videoElement;
            this.isDragging = false;
            this.eventListeners = new Set();

            VideoState.registerInstance(this);
            const initialState = VideoState.getInitialState(this.video);

            if (initialState.isMuted !== VideoState.preferences.isMuted) {
                VideoState.update('isMuted', initialState.isMuted);
            }
            if (initialState.volume !== VideoState.preferences.volume && initialState.volume !== 0) {
                VideoState.update('volume', initialState.volume);
            }

            this.applyState(VideoState.preferences);
            this.container = this.createControlsContainer();
            this.initializeVideoState();
        }

        addEventListener(element, type, handler) {
            element.addEventListener(type, handler);
            this.eventListeners.add({ element, type, handler });
        }

        removeAllEventListeners() {
            this.eventListeners.forEach(({ element, type, handler }) => {
                element.removeEventListener(type, handler);
            });
            this.eventListeners.clear();
        }

        createControlComponent(options) {
            const control = DOMUtils.createContainer({
                className: options.className,
                style: { marginRight: '12px', ...options.style }
            });

            const button = DOMUtils.createButton({
                className: options.buttonClassName,
                style: options.buttonStyle,
                innerHTML: options.buttonContent,
                onclick: options.onClick
            });

            control.appendChild(button);

            if (options.menu) {
                control.appendChild(options.menu);
                DOMUtils.setupHoverMenu(control, options.menu);
            }

            return { control, button };
        }

        createControlsContainer() {
            const container = DOMUtils.createContainer({
                className: 'ig-video-control',
                style: {
                    width: '100%',
                    height: UI.sizes.controlHeight,
                    background: UI.colors.background,
                    display: 'flex',
                    flexDirection: 'column',
                    zIndex: '9999999',
                    position: 'relative',
                    pointerEvents: 'all'
                }
            });

            const timeline = this.createTimeline();
            const controls = this.createControlsRow();

            container.appendChild(timeline);
            container.appendChild(controls);

            return container;
        }

        createControlsRow() {
            const row = DOMUtils.createContainer({
                className: 'ig-video-controls-row',
                style: {
                    display: 'flex',
                    alignItems: 'center',
                    padding: '0 12px',
                    height: '28px',
                    position: 'relative',
                    zIndex: '9999999'
                }
            });

            const controls = [
                this.createPlayButton(),
                this.createTimeDisplay(),
                this.createSpeedControl(),
                this.createBackgroundPlayControl(),
                this.createVolumeControl()
            ];

            controls.forEach(control => row.appendChild(control));
            return row;
        }

        createPlayButton() {
            const updatePlayButton = (button) => {
                button.innerHTML = this.video.paused ? '⏵️' : '⏸️';
            };

            const { control, button } = this.createControlComponent({
                className: 'ig-video-play-control',
                buttonClassName: 'ig-video-control-button',
                buttonStyle: {
                    fontSize: '24px',
                    width: UI.sizes.buttonSize,
                    height: UI.sizes.buttonSize
                },
                buttonContent: this.video.paused ? '⏵️' : '⏸️',
                onClick: (e) => {
                    e.stopPropagation();
                    if (this.video.paused) {
                        this.video.play();
                    } else {
                        this.video.pause();
                    }
                }
            });

            this.addEventListener(this.video, 'play', () => updatePlayButton(button));
            this.addEventListener(this.video, 'pause', () => updatePlayButton(button));

            return control;
        }

        createTimeDisplay() {
            const display = document.createElement('span');
            display.className = 'ig-video-time-display';
            Object.assign(display.style, {
                color: UI.colors.text,
                fontSize: UI.sizes.fontSize.normal,
                marginRight: '12px',
                fontFamily: 'monospace'
            });

            const updateTime = () => {
                const current = Math.floor(this.video.currentTime);
                const total = Math.floor(this.video.duration);
                display.textContent = `${DOMUtils.formatTime(current)} / ${DOMUtils.formatTime(total)}`;
            };

            this.addEventListener(this.video, 'timeupdate', updateTime);
            this.addEventListener(this.video, 'loadedmetadata', updateTime);
            updateTime();

            return display;
        }

        createSpeedControl() {
            const options = this.createSpeedOptions();
            options.style.display = 'none';
            options.dataset.displayType = 'flex';

            return this.createControlComponent({
                className: 'ig-video-speed-control',
                buttonClassName: 'ig-video-speed-button',
                buttonStyle: {
                    fontSize: UI.sizes.fontSize.normal,
                    padding: '4px 8px'
                },
                buttonContent: `${this.video.playbackRate}x`,
                menu: options
            }).control;
        }

        createSpeedOptions() {
            const container = document.createElement('div');
            container.className = 'ig-video-speed-options';
            Object.assign(container.style, {
                position: 'absolute',
                top: '100%',
                left: '50%',
                transform: 'translateX(-50%)',
                background: UI.colors.background,
                borderRadius: '4px',
                flexDirection: 'column',
                marginTop: '8px',
                minWidth: '80px',
                zIndex: '10000001'
            });

            [0.25, 0.5, 0.75, 1, 1.25, 1.5, 1.75, 2].forEach(speed => {
                const option = DOMUtils.createButton({
                    className: 'ig-video-speed-option',
                    style: {
                        padding: '8px 16px',
                        fontSize: UI.sizes.fontSize.normal,
                        width: '100%',
                        textAlign: 'center'
                    },
                    innerHTML: `${speed}x`,
                    onclick: (e) => {
                        e.stopPropagation();
                        this.video.playbackRate = speed;
                        VideoState.update('speed', speed);
                        container.style.display = 'none';
                    }
                });

                container.appendChild(option);
            });

            return container;
        }

        createVolumeControl() {
            const sliderContainer = document.createElement('div');
            sliderContainer.className = 'ig-video-volume-slider-container';
            Object.assign(sliderContainer.style, {
                position: 'absolute',
                left: '25px',
                top: '50%',
                transform: 'translateY(-50%)',
                background: UI.colors.background,
                padding: '10px',
                borderRadius: '4px',
                display: 'none'
            });

            const slider = this.createVolumeSlider();
            sliderContainer.appendChild(slider);

            const { control } = this.createControlComponent({
                className: 'ig-video-volume-control',
                buttonClassName: 'ig-video-volume-button',
                buttonStyle: {
                    fontSize: UI.sizes.fontSize.large,
                    width: UI.sizes.buttonSize,
                    height: UI.sizes.buttonSize
                },
                buttonContent: this.video.muted ? '🔇' : '🔊',
                onClick: (e) => {
                    e.stopPropagation();
                    this.video.muted = !this.video.muted;
                    VideoState.update('isMuted', this.video.muted);
                    this.updateVolumeUI();
                },
                menu: sliderContainer
            });

            return control;
        }

        createVolumeSlider() {
            const slider = document.createElement('input');
            slider.type = 'range';
            slider.min = '0';
            slider.max = '100';
            slider.value = this.video.volume * 100;
            slider.className = 'ig-video-slider';
            Object.assign(slider.style, {
                width: '100px',
                height: '4px',
                background: 'rgba(255,255,255,0.2)',
                cursor: 'pointer'
            });

            this.addEventListener(slider, 'input', (e) => {
                const value = e.target.value / 100;
                this.video.volume = value;
                this.video.muted = value === 0;
                VideoState.update('volume', value);
                VideoState.update('isMuted', this.video.muted);
                this.updateVolumeUI();
            });

            return slider;
        }


        // ... continuing VideoControls class

        createBackgroundPlayControl() {
            const updateBgPlayButton = (button) => {
                button.innerHTML = VideoState.preferences.backgroundPlay ? '🔓' : '🔒';
                button.title = VideoState.preferences.backgroundPlay ?
                    'Video will continue in background' :
                    'Video will pause in background';
                button.style.opacity = VideoState.preferences.backgroundPlay ? '1' : '0.7';
            };

            const { control, button } = this.createControlComponent({
                className: 'ig-video-bgplay-control',
                buttonClassName: 'ig-video-control-button',
                buttonStyle: {
                    fontSize: UI.sizes.fontSize.normal,
                    padding: '4px 8px',
                    opacity: VideoState.preferences.backgroundPlay ? '1' : '0.7'
                },
                buttonContent: VideoState.preferences.backgroundPlay ? '🔓' : '🔒',
                onClick: (e) => {
                    e.stopPropagation();
                    const newState = !VideoState.preferences.backgroundPlay;
                    VideoState.update('backgroundPlay', newState);
                    updateBgPlayButton(button);
                    newState ? this.enableBackgroundPlay() : this.disableBackgroundPlay();
                }
            });

            return control;
        }

        createTimeline() {
            const { timeline, progress, seekHandle, tooltip } = this.createTimelineElements();
            const container = this.setupTimelineContainer(timeline, progress, seekHandle, tooltip);
            this.setupTimelineEvents(container, timeline, progress, seekHandle, tooltip);
            return container;
        }

        createTimelineElements() {
            const timeline = document.createElement('div');
            timeline.className = 'ig-video-timeline';
            Object.assign(timeline.style, {
                width: '100%',
                height: UI.sizes.timelineHeight,
                background: 'rgba(255,255,255,0.2)',
                position: 'relative',
                transition: 'height 0.1s'
            });

            const progress = document.createElement('div');
            progress.className = 'ig-video-progress';
            Object.assign(progress.style, {
                height: '100%',
                background: UI.colors.primary,
                width: '0%',
                position: 'absolute',
                top: '0',
                left: '0'
            });

            const seekHandle = document.createElement('div');
            seekHandle.className = 'ig-video-seek-handle';
            Object.assign(seekHandle.style, {
                width: '12px',
                height: '12px',
                background: UI.colors.primary,
                borderRadius: '50%',
                position: 'absolute',
                right: '-6px',
                top: '50%',
                transform: 'translateY(-50%) scale(0)',
                transition: 'transform 0.1s'
            });

            const tooltip = document.createElement('div');
            tooltip.className = 'ig-video-tooltip';
            Object.assign(tooltip.style, {
                position: 'absolute',
                background: UI.colors.background,
                color: UI.colors.text,
                padding: '4px 8px',
                borderRadius: '4px',
                fontSize: UI.sizes.fontSize.normal,
                bottom: '100%',
                transform: 'translateX(-50%)',
                display: 'none',
                zIndex: '10000000',
                pointerEvents: 'none',
                whiteSpace: 'nowrap',
                marginBottom: '8px'
            });

            progress.appendChild(seekHandle);
            return { timeline, progress, seekHandle, tooltip };
        }

        setupTimelineContainer(timeline, progress, seekHandle, tooltip) {
            const container = DOMUtils.createContainer({
                className: 'ig-video-timeline-container',
                style: {
                    width: '100%',
                    height: '20px',
                    position: 'relative',
                    cursor: 'pointer',
                    padding: '8px 0',
                    zIndex: '9999999'
                }
            });

            timeline.appendChild(progress);
            container.appendChild(timeline);
            container.appendChild(tooltip);

            return container;
        }

        setupTimelineEvents(container, timeline, progress, seekHandle, tooltip) {
            const updateTimelinePosition = (e) => {
                if (!this.isDragging) return;

                const rect = timeline.getBoundingClientRect();
                const pos = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
                const newTime = this.video.duration * pos;

                progress.style.width = `${pos * 100}%`;
                tooltip.style.left = `${pos * 100}%`;
                tooltip.textContent = DOMUtils.formatTime(newTime);
                this.video.currentTime = newTime;
            };

            this.setupTimelineDragEvents(container, timeline, seekHandle, updateTimelinePosition);
            this.setupTimelineHoverEvents(container, timeline, seekHandle, tooltip);
            this.setupTimelineProgressUpdate(progress);
        }

        setupTimelineDragEvents(container, timeline, seekHandle, updateTimelinePosition) {
            this.addEventListener(container, 'mousedown', (e) => {
                e.stopPropagation();
                this.isDragging = true;
                timeline.style.height = UI.sizes.timelineActiveHeight;
                seekHandle.style.transform = 'translateY(-50%) scale(1)';
                updateTimelinePosition(e);

                const handleMouseMove = (e) => updateTimelinePosition(e);
                const handleMouseUp = () => {
                    this.isDragging = false;
                    timeline.style.height = UI.sizes.timelineHeight;
                    seekHandle.style.transform = 'translateY(-50%) scale(0)';
                    document.removeEventListener('mousemove', handleMouseMove);
                    document.removeEventListener('mouseup', handleMouseUp);
                };

                document.addEventListener('mousemove', handleMouseMove);
                document.addEventListener('mouseup', handleMouseUp);
            });
        }

        setupTimelineHoverEvents(container, timeline, seekHandle, tooltip) {
            this.addEventListener(container, 'mousemove', (e) => {
                const rect = timeline.getBoundingClientRect();
                const pos = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));

                if (!this.isDragging) {
                    tooltip.style.display = 'block';
                    tooltip.style.left = `${pos * 100}%`;
                    const previewTime = this.video.duration * pos;
                    tooltip.textContent = DOMUtils.formatTime(previewTime);
                }
            });

            this.addEventListener(container, 'mouseleave', () => {
                if (!this.isDragging) {
                    tooltip.style.display = 'none';
                    timeline.style.height = UI.sizes.timelineHeight;
                    seekHandle.style.transform = 'translateY(-50%) scale(0)';
                }
            });

            this.addEventListener(container, 'mouseenter', () => {
                if (!this.isDragging) {
                    timeline.style.height = UI.sizes.timelineActiveHeight;
                    seekHandle.style.transform = 'translateY(-50%) scale(1)';
                }
            });
        }

        setupTimelineProgressUpdate(progress) {
            this.addEventListener(this.video, 'timeupdate', () => {
                if (!this.isDragging) {
                    const progressValue = (this.video.currentTime / this.video.duration) * 100;
                    progress.style.width = `${progressValue}%`;
                }
            });
        }

        initializeVideoState() {
            this.setupVideoListeners();
            this.applyState(VideoState.preferences);
        }

        setupVideoListeners() {
            this.addEventListener(this.video, 'volumechange', () => {
                if (this.video.muted !== VideoState.preferences.isMuted) {
                    VideoState.update('isMuted', this.video.muted);
                    this.updateVolumeUI();
                }
                if (!this.video.muted && this.video.volume !== VideoState.preferences.volume) {
                    VideoState.update('volume', this.video.volume);
                    this.updateVolumeUI();
                }
            });

            this.addEventListener(this.video, 'ratechange', () => {
                if (this.video.playbackRate !== VideoState.preferences.speed) {
                    VideoState.update('speed', this.video.playbackRate);
                    this.updateSpeedUI();
                }
            });
        }

        applyState(state) {
            if (this.video) {
                this.video.playbackRate = state.speed;
                this.video.volume = state.volume;
                this.video.muted = state.isMuted;

                if (this.container) {
                    this.updateVolumeUI();
                    this.updateSpeedUI();
                    this.updateBackgroundPlayUI();
                }

                state.backgroundPlay ? this.enableBackgroundPlay() : this.disableBackgroundPlay();
            }
        }

        updateVolumeUI() {
            const button = this.container.querySelector('.ig-video-volume-button');
            const slider = this.container.querySelector('.ig-video-slider');
            if (button) button.innerHTML = this.video.muted ? '🔇' : '🔊';
            if (slider) slider.value = this.video.muted ? 0 : this.video.volume * 100;
        }

        updateSpeedUI() {
            const button = this.container.querySelector('.ig-video-speed-button');
            if (button) button.innerHTML = `${this.video.playbackRate}x`;
        }

        updateBackgroundPlayUI() {
            const button = this.container.querySelector('.ig-video-bgplay-control .ig-video-control-button');
            if (button) {
                button.innerHTML = VideoState.preferences.backgroundPlay ? '🔓' : '🔒';
                button.style.opacity = VideoState.preferences.backgroundPlay ? '1' : '0.7';
            }
        }

        enableBackgroundPlay() {
            if (!this.video._originalPause) {
                this.video._originalPause = this.video.pause;
                this.video.pause = () => {
                    if (document.visibilityState === 'hidden' && !this.video.ended) {
                        return Promise.resolve();
                    }
                    return this.video._originalPause.call(this.video);
                };
            }
        }

        disableBackgroundPlay() {
            if (this.video._originalPause) {
                this.video.pause = this.video._originalPause;
                delete this.video._originalPause;
            }
        }

        destroy() {
            VideoState.unregisterInstance(this);
            this.removeAllEventListeners();
            if (this.container) {
                this.container.remove();
            }
        }
    }

    // Add global styles
    const addStyles = () => {
        const styles = `
            .ig-video-slider::-webkit-slider-thumb {
                -webkit-appearance: none;
                width: 12px;
                height: 12px;
                background: ${UI.colors.primary};
                border-radius: 50%;
                cursor: pointer;
            }
            .ig-video-slider::-moz-range-thumb {
                width: 12px;
                height: 12px;
                background: ${UI.colors.primary};
                border-radius: 50%;
                cursor: pointer;
                border: none;
            }
            .ig-video-control-button:hover {
                opacity: 0.8;
            }
            .ig-video-speed-option:hover {
                background: ${UI.colors.hover};
            }
        `;

        const styleSheet = document.createElement('style');
        styleSheet.textContent = styles;
        document.head.appendChild(styleSheet);
    };

    // Initialize video controls with enhanced observer
    const initVideoControls = () => {
        const processedVideos = new WeakSet();

        const addControlsToVideo = (videoElement) => {
            if (processedVideos.has(videoElement)) return;

            const videoContainer = videoElement.closest('div[class*="x5yr21d"][class*="x1uhb9sk"]');
            if (!videoContainer) return;

            processedVideos.add(videoElement);
            const controls = new VideoControls(videoElement);

            const controlsWrapper = DOMUtils.createContainer({
                className: 'ig-video-controls-wrapper',
                style: {
                    width: '100%',
                    position: 'relative',
                    zIndex: '9999999'
                }
            });

            controlsWrapper.appendChild(controls.container);
            videoContainer.parentElement.insertBefore(controlsWrapper, videoContainer);
            videoContainer.style.position = 'relative';
            videoContainer.style.zIndex = '1';

            const observer = new MutationObserver((mutations) => {
                if (!document.contains(videoElement)) {
                    controls.destroy();
                    observer.disconnect();
                }
            });

            observer.observe(document.body, {
                childList: true,
                subtree: true
            });
        };

        const observer = new MutationObserver((mutations) => {
            mutations.forEach(mutation => {
                mutation.addedNodes.forEach(node => {
                    if (node.nodeName === 'VIDEO') {
                        addControlsToVideo(node);
                    } else if (node.querySelectorAll) {
                        node.querySelectorAll('video').forEach(addControlsToVideo);
                    }
                });
            });
        });

        observer.observe(document.body, {
            childList: true,
            subtree: true,
            attributes: true,
            attributeFilter: ['src', 'style', 'class']
        });

        document.querySelectorAll('video').forEach(addControlsToVideo);
    };

    // Initialize everything
    if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', () => {
            VideoState.initialize();
            addStyles();
            initVideoControls();
        });
    } else {
        VideoState.initialize();
        addStyles();
        initVideoControls();
    }
})();