Starblast Game Recorder

Recording + replay via WebSocket simulation with user data protection

您需要先安装一个扩展,例如 篡改猴Greasemonkey暴力猴,之后才能安装此脚本。

You will need to install an extension such as Tampermonkey to install this script.

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴Userscripts ,之后才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。

您需要先安装用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name            Starblast Game Recorder
// @name:ru         Starblast Game Recorder
// @namespace       https://greasyfork.org/ru/users/1252274-julia1233
// @version         1.8.8
// @description     Recording + replay via WebSocket simulation with user data protection
// @description:ru  Запись и воспроизведение сессий Starblast.io с защитой данных пользователя
// @author          Julia1233
// @license         GPL-3.0-or-later; https://www.gnu.org/licenses/gpl-3.0.txt
// @homepage        https://greasyfork.org/ru/scripts/554572-starblast-game-recorder/
// @supportURL      https://greasyfork.org/ru/scripts/554572-starblast-game-recorder/feedback
// @match           https://starblast.io/*
// @grant           none
// @icon            https://starblast.io/static/img/icon64.png
// @run-at          document-start
// ==/UserScript==

/*
 * Starblast Game Recorder v1.8.8
 * Copyright (c) 2025 Julia1233
 * 
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 * 
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 * 
 * You should have received a copy of the GNU General Public License
 * along with this program.  If not, see <https://www.gnu.org/licenses/>.
 */

(function () {
    'use strict';

    window.__allWebSockets = [];
    window.__recorderInstance = null;
    window.__playingRecording = null;
    window.__wsListeners = [];
    window.__fakeServerMode = false;
    window.__hideOverlay = false;
    window.__loadedRecording = null;

    const OriginalWebSocket = window.WebSocket;
    const OriginalSend = OriginalWebSocket.prototype.send;
    const OriginalAddEventListener = OriginalWebSocket.prototype.addEventListener;

    OriginalWebSocket.prototype.send = function (data) {
        const recorder = window.__recorderInstance;
        if (recorder && !window.__isPlayingMessage) {
            recorder.recordOutgoingMessage(data);
        }
        return OriginalSend.call(this, data);
    };

    OriginalWebSocket.prototype.addEventListener = function (type, listener, options) {
        if (type === 'message') {
            window.__wsListeners.push({ ws: this, listener: listener });

            const wrappedListener = function (event) {
                const recorder = window.__recorderInstance;
                if (recorder && event.data && !window.__isPlayingMessage) {
                    recorder.recordIncomingMessage(event.data);
                }
                listener.call(this, event);
            };
            return OriginalAddEventListener.call(this, type, wrappedListener, options);
        }
        return OriginalAddEventListener.call(this, type, listener, options);
    };

    let onmessageHandler = null;
    Object.defineProperty(OriginalWebSocket.prototype, 'onmessage', {
        get: function () {
            return onmessageHandler;
        },
        set: function (handler) {
            onmessageHandler = handler;

            if (handler) {
                const wrappedHandler = function (event) {
                    const recorder = window.__recorderInstance;
                    if (recorder && event.data && !window.__isPlayingMessage) {
                        recorder.recordIncomingMessage(event.data);
                    }
                    handler.call(this, event);
                };
                OriginalAddEventListener.call(this, 'message', wrappedHandler);
            }
        },
        enumerable: true,
        configurable: true
    });

    window.WebSocket = function (...args) {
        if (window.__fakeServerMode) {
            console.log('[Recorder] Creating FAKE WebSocket for replay');
            return createFakeWebSocket(args[0]);
        }

        const ws = new OriginalWebSocket(...args);
        window.__allWebSockets.push(ws);
        return ws;
    };

    window.WebSocket.prototype = OriginalWebSocket.prototype;
    Object.setPrototypeOf(window.WebSocket, OriginalWebSocket);

    function createFakeWebSocket(url) {
        const fake = {
            url: url,
            readyState: 0,
            bufferedAmount: 0,
            extensions: '',
            protocol: '',
            binaryType: 'arraybuffer',

            listeners: {
                open: [],
                message: [],
                error: [],
                close: []
            },

            addEventListener: function (type, listener, options) {
                if (this.listeners[type]) {
                    this.listeners[type].push(listener);
                }
            },

            removeEventListener: function (type, listener, options) {
                if (this.listeners[type]) {
                    const idx = this.listeners[type].indexOf(listener);
                    if (idx !== -1) this.listeners[type].splice(idx, 1);
                }
            },

            send: function (data) {
                // Игнорируем отправку
            },

            close: function (code, reason) {
                this.readyState = 3;
                const closeEvent = new CloseEvent('close', {
                    code: code || 1000,
                    reason: reason || '',
                    wasClean: true
                });
                this.listeners.close.forEach(fn => fn.call(this, closeEvent));
                if (this.onclose) this.onclose(closeEvent);
            },

            dispatchEvent: function (event) {
                const type = event.type;
                if (this.listeners[type]) {
                    this.listeners[type].forEach(fn => fn.call(this, event));
                }
                return true;
            }
        };

        setTimeout(() => {
            fake.readyState = 1;
            const openEvent = new Event('open');
            fake.listeners.open.forEach(fn => fn.call(fake, openEvent));
            if (fake.onopen) fake.onopen(openEvent);

            console.log('[FakeWS] Connection opened, starting playback...');

            const recorder = window.__recorderInstance;
            if (recorder && window.__playingRecording) {
                recorder.startAutoPlayback(fake);
            }
        }, 100);

        window.__allWebSockets.push(fake);
        return fake;
    }

    function keepOverlayHidden() {
        if (window.__hideOverlay) {
            const overlay = document.getElementById('overlay');
            if (overlay) {
                overlay.style.display = 'none !important';
            }
        }
        requestAnimationFrame(keepOverlayHidden);
    }
    keepOverlayHidden();

    function sanitizeSensitiveData(str) {
        try {
            const obj = JSON.parse(str);

            if (obj.data) {
                if (obj.data.ecp_key !== undefined) {
                    obj.data.ecp_key = '';
                }
                if (obj.data.key !== undefined) {
                    obj.data.key = '';
                }
                if (obj.data.steamid !== undefined) {
                    obj.data.steamid = null;
                }
            }

            return JSON.stringify(obj);
        } catch (e) {
            return str;
        }
    }

    class GameRecorder {
        constructor() {
            this.isRecording = false;
            this.isPlayback = false;
            this.isPaused = false;
            this.recordedMessages = [];
            this.playbackIndex = 0;
            this.playbackSpeed = 1.0;
            this.autoRecordEnabled = true;
            this.autoStarted = false;
            this.fakeWs = null;
            this.uiVisible = false;
            this.recordingStartTime = null;
            this.isOver100Seconds = false;
            this.mouseInputBlocked = false;

            window.__recorderInstance = this;
            this.loadAutoRecordState();
            this.setupUI();
            this.setupKeyboardShortcuts();
            this.recordMouseMove();
        }

        loadAutoRecordState() {
            const saved = localStorage.getItem('recorder_auto_record');
            if (saved === null) {
                this.autoRecordEnabled = false;
            } else {
                this.autoRecordEnabled = JSON.parse(saved);
            }
        }

        saveAutoRecordState() {
            localStorage.setItem('recorder_auto_record', JSON.stringify(this.autoRecordEnabled));
        }

        recordIncomingMessage(data) {
            const dataInfo = this.identifyData(data);

            if (!this.autoStarted && this.autoRecordEnabled) {
                if (this.shouldAutoStart(data)) {
                    this.autoStarted = true;
                    this.startRecording();
                    const now = new Date();
                    const timeStr = `${now.getHours()}:${String(now.getMinutes()).padStart(2, '0')}:${String(now.getSeconds()).padStart(2, '0')}`;
                    document.getElementById('recording-name').value = `session_${timeStr}`;
                    document.getElementById('record-status').textContent = '🔴 AUTO (↓)';
                }
            }

            if (this.isRecording) {
                this.recordedMessages.push({
                    type: 'in',
                    rawData: data,
                    timestamp: Date.now()
                });
                this.updateRecordStatus();
                this.checkAutoSave();
            }
        }

        recordOutgoingMessage(data) {
            const dataInfo = this.identifyData(data);

            if (!this.autoStarted && this.autoRecordEnabled) {
                if (this.shouldAutoStart(data)) {
                    this.autoStarted = true;
                    this.startRecording();
                    const now = new Date();
                    const timeStr = `${now.getHours()}:${String(now.getMinutes()).padStart(2, '0')}:${String(now.getSeconds()).padStart(2, '0')}`;
                    document.getElementById('recording-name').value = `session_${timeStr}`;
                    document.getElementById('record-status').textContent = '🔴 AUTO (↑)';
                }
            }

            if (this.isRecording) {
                this.recordedMessages.push({
                    type: 'out',
                    rawData: data,
                    timestamp: Date.now()
                });
                this.updateRecordStatus();
                this.checkAutoSave();
            }
        }

        checkAutoSave() {
            if (!this.isRecording || !this.recordingStartTime) return;
            if (this.recordedMessages.length === 0) return;

            const currentTime = this.recordedMessages[this.recordedMessages.length - 1].timestamp;
            const baseTime = this.recordedMessages[0].timestamp;
            const duration = (currentTime - baseTime) / 1000;

            if (duration >= 100 && !this.isOver100Seconds) {
                this.isOver100Seconds = true;
                document.getElementById('record-status').textContent = '⚠️ 100s! File only';
                document.getElementById('btn-save').style.background = '#ff6600';
            }
        }

        identifyData(data) {
            if (data instanceof Blob) {
                return { type: 'Blob', size: data.size };
            } else if (data instanceof ArrayBuffer) {
                return { type: 'ArrayBuffer', size: data.byteLength };
            } else if (ArrayBuffer.isView(data)) {
                return { type: 'TypedArray', size: data.byteLength };
            } else if (typeof data === 'string') {
                return { type: 'string', size: data.length };
            }
            return { type: 'unknown', size: 0 };
        }

        async serializeDataAsync(data) {
            if (data instanceof Blob) {
                const arrayBuffer = await data.arrayBuffer();
                return {
                    type: 'buffer',
                    value: Array.from(new Uint8Array(arrayBuffer))
                };
            } else if (data instanceof ArrayBuffer) {
                return {
                    type: 'buffer',
                    value: Array.from(new Uint8Array(data))
                };
            } else if (ArrayBuffer.isView(data)) {
                return {
                    type: 'buffer',
                    value: Array.from(data)
                };
            } else if (typeof data === 'string') {
                const sanitized = sanitizeSensitiveData(data);
                return {
                    type: 'string',
                    value: sanitized
                };
            }
            return {
                type: 'string',
                value: String(data)
            };
        }

        deserializeData(serialized) {
            if (serialized.type === 'buffer') {
                const arrayBuffer = new Uint8Array(serialized.value).buffer;
                return new Blob([arrayBuffer]);
            }
            return serialized.value;
        }

        shouldAutoStart(data) {
            if (data instanceof Blob || data instanceof ArrayBuffer || ArrayBuffer.isView(data)) {
                return true;
            }
            if (typeof data === 'string') {
                const lower = data.toLowerCase();
                if (lower === 'ping' || lower === 'pong' || data === '2' || data === '3') return false;
                return data.length >= 5;
            }
            return false;
        }

        setupUI() {
            const container = document.createElement('div');
            container.id = 'recorder-ui';
            container.style.cssText = `
                position: fixed;
                top: 20px;
                right: 20px;
                z-index: 10000;
                background: rgba(0, 0, 0, 0.95);
                padding: 15px;
                border-radius: 8px;
                border: 2px solid #00ff00;
                font-family: 'Play';
                color: #0f0;
                min-width: 320px;
                max-width: 400px;
                box-shadow: 0 0 20px rgba(0, 255, 0, 0.5);
                display: none;
            `;

            container.innerHTML = `
                <div style="font-weight: bold; color: #0f0; font-size: 14px; margin-bottom: 10px;">🎮 RECORDER v1.8.8 (Shift+R)</div>
                
                <div style="border-bottom: 1px solid #0f0; margin: 10px 0; padding: 10px 0;">
                    <div style="font-size: 12px; margin-bottom: 8px;">📝 RECORDING:</div>
                    <div style="display: flex; gap: 5px;">
                        <button id="btn-record" style="flex: 1; padding: 8px; background: #0f0; color: #000; border: none; border-radius: 4px; cursor: pointer; font-weight: bold; font-size: 11px;">⚫ START</button>
                        <button id="btn-stop" disabled style="flex: 1; padding: 8px; background: #333; color: #0f0; border: 1px solid #0f0; border-radius: 4px; cursor: pointer; font-weight: bold; font-size: 11px;">⏹ STOP</button>
                    </div>
                    <div style="display: flex; gap: 5px; align-items: center; margin: 8px 0;">
                        <input type="checkbox" id="auto-record" style="width: 18px; height: 18px;">
                        <label for="auto-record" style="font-size: 11px; cursor: pointer;">🔄 Auto</label>
                    </div>
                    <input type="text" id="recording-name" placeholder="session_name" style="width: 95.5%; padding: 6px; margin: 5px 0; border: 1px solid #0f0; border-radius: 4px; background: #111; color: #0f0; font-family: monospace; font-size: 11px;">
                    <button id="btn-save" disabled style="width: 100%; padding: 8px; background: #0f0; color: #000; border: none; border-radius: 4px; cursor: pointer; font-weight: bold; font-size: 11px;">💾 SAVE</button>
                    <div id="record-status" style="margin-top: 8px; font-size: 10px; color: #0f0; text-align: center;">Ready</div>
                </div>

                <div style="border-bottom: 1px solid #0f0; margin: 10px 0; padding: 10px 0;">
                    <div style="font-size: 12px; margin-bottom: 8px;">▶ PLAYBACK:</div>
                    
                    <div style="display: flex; gap: 5px; margin: 8px 0;">
                        <button id="btn-import" style="flex: 1; padding: 8px; background: #00ff00; color: #000; border: none; border-radius: 4px; cursor: pointer; font-weight: bold; font-size: 11px;">📥 IMPORT</button>
                        <button id="btn-replay" disabled style="flex: 1; padding: 8px; background: #ff8800; color: #000; border: none; border-radius: 4px; cursor: pointer; font-weight: bold; font-size: 11px;">🎬 REPLAY</button>
                    </div>

                    <div style="display: flex; gap: 5px; margin: 8px 0;">
                        <button id="btn-pause" disabled style="flex: 1; padding: 8px; background: #0f0; color: #000; border: none; border-radius: 4px; cursor: pointer; font-weight: bold; font-size: 11px;">⏸ PAUSE</button>
                        <button id="btn-stop-play" disabled style="flex: 1; padding: 8px; background: #0f0; color: #000; border: none; border-radius: 4px; cursor: pointer; font-weight: bold; font-size: 11px;">⏹ STOP</button>
                    </div>

                    <div style="display: flex; gap: 5px; align-items: center; font-size: 10px;">
                        <label style="flex: 1;">Speed:</label>
                        <input type="range" id="speed-control" min="0.25" max="4" step="0.25" value="1" style="flex: 2;">
                        <span id="speed-display" style="width: 35px;">1x</span>
                    </div>

                    <div style="margin-top: 8px;">
                        <input type="range" id="timeline-scrubber" min="0" max="100" value="0" style="width: 100%; height: 4px;">
                        <div id="playback-time" style="font-size: 9px; color: #0f0; text-align: center; margin-top: 4px;">0s / 0s</div>
                    </div>

                    <div id="playback-info" style="margin-top: 8px; font-size: 9px; color: #888; padding: 8px; background: #111; border-radius: 4px; max-height: 60px; overflow-y: auto;">No recording loaded</div>
                </div>
            `;
            document.body.appendChild(container);
            document.getElementById('auto-record').checked = this.autoRecordEnabled;
            this.setupEventListeners();
        }

        setupKeyboardShortcuts() {
            document.addEventListener('keydown', (e) => {
                if (e.shiftKey && (e.code === 'KeyR' || (e.key && e.key.toLowerCase() === 'r'))) {
                    e.preventDefault();
                    this.toggleUI();
                }
            });
        }

        toggleUI() {
            this.uiVisible = !this.uiVisible;
            const container = document.getElementById('recorder-ui');

            if (this.uiVisible) {
                container.style.display = 'block';
            } else {
                container.style.display = 'none';
            }
        }

        setupEventListeners() {
            document.getElementById('btn-record').addEventListener('click', () => this.startRecording());
            document.getElementById('btn-stop').addEventListener('click', () => this.stopRecording());
            document.getElementById('btn-save').addEventListener('click', () => this.saveRecording());
            document.getElementById('btn-import').addEventListener('click', () => this.importRecording());
            document.getElementById('btn-replay').addEventListener('click', () => this.startReplayMode());
            document.getElementById('btn-pause').addEventListener('click', () => this.togglePause());
            document.getElementById('btn-stop-play').addEventListener('click', () => this.stopPlayback());

            document.getElementById('auto-record').addEventListener('change', (e) => {
                this.autoRecordEnabled = e.target.checked;
                this.saveAutoRecordState();
                this.autoStarted = false;
                document.getElementById('record-status').textContent = e.target.checked ? 'Auto Ready' : 'Auto OFF';
            });

            document.getElementById('speed-control').addEventListener('input', (e) => {
                this.playbackSpeed = parseFloat(e.target.value);
                document.getElementById('speed-display').textContent = this.playbackSpeed.toFixed(2) + 'x';
            });

            document.getElementById('timeline-scrubber').addEventListener('input', (e) => {
                if (!this.isPlayback) return;
                const percent = parseFloat(e.target.value) / 100;
                this.seekTo(percent);
            });
        }

        startRecording() {
            if (this.isRecording) return;
            this.isRecording = true;
            this.recordingStartTime = Date.now();
            this.recordedMessages = [];
            this.isOver100Seconds = false;
            document.getElementById('btn-record').disabled = true;
            document.getElementById('btn-stop').disabled = false;
            document.getElementById('btn-save').disabled = true;
            document.getElementById('btn-save').style.background = '#0f0';
        }

        recordMouseMove() {
            document.addEventListener('mousemove', (e) => {
                if (this.isRecording && !window.__isPlayingMessage) {
                    this.recordedMessages.push({
                        type: 'mousemove',
                        clientX: e.clientX,
                        clientY: e.clientY,
                        w: window.innerWidth,
                        h: window.innerHeight,
                        timestamp: Date.now()
                    });
                    this.updateRecordStatus();
                }
            });
        }

        stopRecording() {
            if (!this.isRecording) return;
            this.isRecording = false;
            document.getElementById('btn-record').disabled = false;
            document.getElementById('btn-stop').disabled = true;
            document.getElementById('btn-save').disabled = false;

            if (this.recordedMessages.length === 0) {
                document.getElementById('record-status').textContent = '⚠️ No data';
                return;
            }

            const inCount = this.recordedMessages.filter(m => m.type === 'in').length;
            const outCount = this.recordedMessages.filter(m => m.type === 'out').length;
            const baseTime = this.recordedMessages[0].timestamp;
            const lastTime = this.recordedMessages[this.recordedMessages.length - 1].timestamp;
            const duration = (lastTime - baseTime) / 1000;

            document.getElementById('record-status').textContent = `✅ ↓${inCount} ↑${outCount} | ${duration.toFixed(1)}s`;
        }

        async saveRecording() {
            const name = document.getElementById('recording-name').value.trim() || 'unnamed';
            if (this.recordedMessages.length === 0) {
                alert('Nothing to save!');
                return;
            }

            try {
                const serializedMessages = [];
                for (const msg of this.recordedMessages) {
                    let serialized;

                    if (msg.type === 'mousemove') {
                        serialized = {
                            t: 'm',
                            x: msg.clientX,
                            y: msg.clientY,
                            w: msg.w,
                            h: msg.h
                        };
                    } else {
                        serialized = await this.serializeDataAsync(msg.rawData);
                        serialized.t = msg.type;
                    }

                    serializedMessages.push({
                        ...serialized,
                        r: msg.timestamp - this.recordedMessages[0].timestamp
                    });
                }

                const recordingData = {
                    n: name,
                    m: serializedMessages,
                    d: serializedMessages[serializedMessages.length - 1].r,
                    c: serializedMessages.length,
                    v: 1
                };

                const json = JSON.stringify(recordingData);
                console.log(`[Recorder] File size: ${(json.length / 1024).toFixed(2)} KB`);

                const blob = new Blob([json], { type: 'application/json' });
                const url = URL.createObjectURL(blob);
                const a = document.createElement('a');
                a.href = url;
                a.download = `${name}_${Date.now()}.json`;
                a.click();
                URL.revokeObjectURL(url);

                document.getElementById('record-status').textContent = '✅ Saved!';
                document.getElementById('recording-name').value = '';
                document.getElementById('btn-save').style.background = '#0f0';
                this.recordedMessages = [];
                this.isOver100Seconds = false;
                this.autoStarted = false;

                setTimeout(() => {
                    document.getElementById('record-status').textContent = 'Ready';
                }, 1500);

            } catch (err) {
                console.error('[Recorder] Error:', err);
                document.getElementById('record-status').textContent = '❌ Error!';
            }
        }

        startReplayMode() {
            if (!window.__loadedRecording) {
                alert('Import a recording first!');
                return;
            }

            window.__playingRecording = window.__loadedRecording;
            window.__fakeServerMode = true;
            window.__hideOverlay = true;

            alert('Replay mode activated!\n\nNow join the game - it will replay the recording.\n\nRefresh the page after playback ends.');

            document.getElementById('playback-info').textContent = '🎬 REPLAY MODE ACTIVE';
        }

        startAutoPlayback(fakeWs) {
            this.fakeWs = fakeWs;
            this.isPlayback = true;
            this.isPaused = false;
            this.playbackIndex = 0;
            this.playbackStartTime = Date.now();
            this.mouseInputBlocked = false;

            this.blockUserMouseInput();

            document.getElementById('btn-pause').disabled = false;
            document.getElementById('btn-stop-play').disabled = false;

            this.playNextMessage();
        }

        playNextMessage() {
            if (!this.isPlayback || !window.__playingRecording) return;

            const messages = window.__playingRecording.messages;

            if (this.playbackIndex >= messages.length) {
                this.stopPlayback();
                return;
            }

            if (this.isPaused) {
                setTimeout(() => this.playNextMessage(), 50);
                return;
            }

            const currentMsg = messages[this.playbackIndex];
            const nextMsg = messages[this.playbackIndex + 1];

            const elapsedTime = (Date.now() - this.playbackStartTime) / this.playbackSpeed;
            const msgTime = currentMsg.relativeTime;

            if (elapsedTime >= msgTime) {
                if (currentMsg.type === 'm') {
                    this.replayMouseMove(
                        currentMsg.clientX,
                        currentMsg.clientY,
                        currentMsg.w || 1920,
                        currentMsg.h || 1080
                    );
                }
                if (currentMsg.type === 'in') {
                    this.deliverMessage(currentMsg);
                }

                this.playbackIndex++;
                this.updatePlaybackUI(window.__playingRecording);

                setTimeout(() => this.playNextMessage(), 0);
            } else {
                const delay = nextMsg
                    ? Math.max(1, (nextMsg.relativeTime - msgTime) / this.playbackSpeed)
                    : 16;
                setTimeout(() => this.playNextMessage(), Math.min(delay, 100));
            }
        }

        deliverMessage(msgData) {
            window.__isPlayingMessage = true;

            const data = this.deserializeData(msgData.data);

            if (this.fakeWs) {
                const event = new MessageEvent('message', {
                    data: data,
                    bubbles: false,
                    cancelable: false
                });

                this.fakeWs.listeners.message.forEach(fn => fn.call(this.fakeWs, event));
                if (this.fakeWs.onmessage) this.fakeWs.onmessage(event);
            } else {
                window.__wsListeners.forEach(item => {
                    try {
                        const event = new MessageEvent('message', {
                            data: data,
                            bubbles: false,
                            cancelable: false
                        });
                        item.listener.call(item.ws, event);
                    } catch (e) {
                        console.error('[Recorder] Error in listener:', e);
                    }
                });

                window.__allWebSockets.forEach(ws => {
                    try {
                        if (ws.onmessage) {
                            const event = new MessageEvent('message', {
                                data: data,
                                bubbles: false,
                                cancelable: false
                            });
                            ws.onmessage(event);
                        }
                    } catch (e) { }
                });
            }

            window.__isPlayingMessage = false;
        }

        replayMouseMove(clientX, clientY, recordedWidth, recordedHeight) {
            try {
                const currentWidth = window.innerWidth;
                const currentHeight = window.innerHeight;

                const scaleX = recordedWidth > 0 ? currentWidth / recordedWidth : 1;
                const scaleY = recordedHeight > 0 ? currentHeight / recordedHeight : 1;

                const scaledX = clientX * scaleX;
                const scaledY = clientY * scaleY;

                const moveEvent = new MouseEvent('mousemove', {
                    bubbles: true,
                    cancelable: true,
                    view: window,
                    clientX: scaledX,
                    clientY: scaledY,
                    screenX: scaledX,
                    screenY: scaledY
                });

                document.dispatchEvent(moveEvent);

                const canvas = document.querySelector('canvas');
                if (canvas) {
                    canvas.dispatchEvent(moveEvent);
                }
            } catch (e) {
                console.error('[Recorder] Error replaying mouse move:', e);
            }
        }

        blockUserMouseInput() {
            if (this.mouseInputBlocked) return;
            this.mouseInputBlocked = true;

            const blockHandler = (e) => {
                if (e.isTrusted) {
                    e.preventDefault();
                    e.stopPropagation();
                    return false;
                }
            };

            document.addEventListener('mousemove', blockHandler, true);
            document.addEventListener('mousedown', blockHandler, true);
            document.addEventListener('mouseup', blockHandler, true);

            this.mouseBlockHandlers = {
                handler: blockHandler,
                events: ['mousemove', 'mousedown', 'mouseup']
            };
        }

        unblockUserMouseInput() {
            if (!this.mouseInputBlocked) return;
            this.mouseInputBlocked = false;

            if (this.mouseBlockHandlers) {
                this.mouseBlockHandlers.events.forEach(event => {
                    document.removeEventListener(event, this.mouseBlockHandlers.handler, true);
                });
                this.mouseBlockHandlers = null;
            }
        }

        updatePlaybackUI(recordingData) {
            const msgs = recordingData.messages;
            if (this.playbackIndex === 0) return;

            const currentMsg = msgs[this.playbackIndex - 1];
            const progress = (currentMsg.relativeTime / recordingData.totalDuration) * 100;

            document.getElementById('timeline-scrubber').value = progress;

            const currentSec = (currentMsg.relativeTime / 1000).toFixed(1);
            const totalSec = (recordingData.totalDuration / 1000).toFixed(1);
            document.getElementById('playback-time').textContent = `${currentSec}s / ${totalSec}s`;
        }

        togglePause() {
            this.isPaused = !this.isPaused;
            const btn = document.getElementById('btn-pause');
            btn.textContent = this.isPaused ? '▶ RES' : '⏸ PAU';
            btn.style.background = this.isPaused ? '#ff8800' : '#0f0';
        }

        stopPlayback() {
            this.isPlayback = false;
            this.isPaused = false;
            window.__playingRecording = null;
            window.__fakeServerMode = false;
            window.__hideOverlay = false;

            this.unblockUserMouseInput();

            const overlay = document.getElementById('overlay');
            if (overlay) {
                overlay.style.display = 'block';
            }

            document.getElementById('btn-pause').disabled = true;
            document.getElementById('btn-stop-play').disabled = true;
            document.getElementById('btn-pause').textContent = '⏸ PAUSE';
            document.getElementById('btn-pause').style.background = '#0f0';
            document.getElementById('timeline-scrubber').value = 0;
            document.getElementById('playback-time').textContent = '0s / 0s';

            if (this.fakeWs) {
                this.fakeWs.close();
                this.fakeWs = null;
            }
        }

        seekTo(percent) {
            if (!window.__loadedRecording) return;
            const data = window.__loadedRecording;
            const targetTime = data.totalDuration * percent;

            this.playbackIndex = data.messages.findIndex(m => m.relativeTime >= targetTime);
            if (this.playbackIndex < 0) this.playbackIndex = 0;

            this.playbackStartTime = Date.now() - (targetTime / this.playbackSpeed);
        }

        importRecording() {
            const input = document.createElement('input');
            input.type = 'file';
            input.accept = '.json';

            input.onchange = (e) => {
                const file = e.target.files[0];
                if (!file) return;

                const reader = new FileReader();
                reader.onload = (event) => {
                    try {
                        const recordingData = JSON.parse(event.target.result);

                        const data = {
                            name: recordingData.n || recordingData.name || 'imported',
                            messages: recordingData.m || recordingData.messages || [],
                            totalDuration: recordingData.d || recordingData.totalDuration || 0,
                            messageCount: recordingData.c || recordingData.messageCount || 0,
                            version: recordingData.v || 1
                        };

                        if (!data.messages || data.messages.length === 0) {
                            alert('Invalid recording file!');
                            return;
                        }

                        data.messages = data.messages.map(msg => {
                            const normalized = {
                                type: msg.t || msg.type,
                                relativeTime: msg.r || msg.relativeTime || 0
                            };

                            if (msg.t === 'm' || msg.type === 'mousemove') {
                                normalized.clientX = msg.x;
                                normalized.clientY = msg.y;
                                normalized.w = msg.w || 1920;
                                normalized.h = msg.h || 1080;
                            } else {
                                normalized.data = msg.data || msg;
                            }

                            return normalized;
                        });

                        window.__loadedRecording = data;
                        document.getElementById('btn-replay').disabled = false;
                        document.getElementById('playback-info').textContent = `📹 ${data.name}\n⏱ ${(data.totalDuration / 1000).toFixed(1)}s | ${data.messageCount} msg`;

                        alert(`✅ Imported: ${data.name}`);
                    } catch (err) {
                        console.error('[Recorder] Import error:', err);
                        alert('Error importing file: ' + err.message);
                    }
                };
                reader.readAsText(file);
            };

            input.click();
        }

        updateRecordStatus() {
            if (!this.isRecording) return;
            const inCount = this.recordedMessages.filter(m => m.type === 'in').length;
            const outCount = this.recordedMessages.filter(m => m.type === 'out').length;
            const mouseCount = this.recordedMessages.filter(m => m.type === 'mousemove').length;
            const baseTime = this.recordedMessages[0].timestamp;
            const lastTime = this.recordedMessages[this.recordedMessages.length - 1].timestamp;
            const dur = (lastTime - baseTime) / 1000;
            document.getElementById('record-status').textContent = `🔴 ↓${inCount} ↑${outCount} 🖱${mouseCount} | ${dur.toFixed(1)}s`;
        }
    }

    window.__gameRecorder = new GameRecorder();
    console.log('[Recorder] v1.8.8 - Mouse recording & replay system');
})();