Mainsail camera PiP

Скрипт для отображения камеры в Picture-in-Picture.

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @name         Mainsail camera PiP
// @namespace    http://tampermonkey.net/
// @version      0.6
// @description  Скрипт для отображения камеры в Picture-in-Picture.
// @include      /^https?:\/\/(?:\d{1,3}\.){3}\d{1,3}:4409\/.*$/
// @include      /^https?:\/\/[^\/ :]+:4409\/.*$/
// @run-at       document-end
// @license MIT
// ==/UserScript==

(function(){
    'use strict';
    const L = (...a)=>console.info('[PiPWatcher]',...a);
    const E = (...a)=>console.error('[PiPWatcher]',...a);
    let active = null;
    let lastAttemptTime = 0;

    // Конфиг
    const FPS = 15;
    // Конфигурация: установите `false`, чтобы PiP оставался открытым при возврате на вкладку
    // Если `true`, скрипт будет вызывать `document.exitPictureInPicture()` при возврате на вкладку.
    const EXIT_ON_VISIBLE = false;
    // Таймаут (в миллисекундах) между автоматическими попытками входа в PiP
    const ATTEMPT_COOLDOWN_MS = 1500;
    

    // Построить URL потока на основе текущего URL страницы. Если порт не указан — по умолчанию 4409.
    function buildStreamUrlFromLocation() {
        try {
            const p = window.location;
            const protocol = p.protocol || 'http:';
            const hostname = p.hostname;
            if (!hostname) return null;
            const port = p.port && p.port.length ? p.port : '4409';
            return `${protocol}//${hostname}:${port}/webcam/?action=stream`;
        } catch (e) {
            return null;
        }
    }

    function cleanupActive() {
        if (!active) return;
        L('Cleaning up active pipeline');
        try { if (active.timerId) clearInterval(active.timerId); } catch(e){}
        try {
            if (active.video) {
                try {
                    if (document.pictureInPictureElement === active.video) document.exitPictureInPicture().catch(()=>{});
                } catch(e){}
                try { active.video.remove(); } catch(e){}
            }
        } catch(e){}
        try { if (active.streamTracks) active.streamTracks.forEach(t=>t.stop && t.stop()); } catch(e){}
        try { if (active.canvas) active.canvas.remove(); } catch(e){}
        try { if (active.streamImg) active.streamImg.src = ''; } catch(e){}
        active = null;
    }

    // Ждём, когда video перейдёт в состояние playing (или таймаут)
    function waitForPlay(video, timeoutMs = 3000) {
        return new Promise((resolve) => {
            let done = false;
            const finish = (ok) => { if (done) return; done = true; try { video.removeEventListener('playing', onPlaying); } catch(e){} resolve(ok); };
            const onPlaying = () => finish(true);
            try { video.addEventListener('playing', onPlaying); } catch(e){}
            // также проверяем readyState
            if (!video.paused && video.readyState >= 3) return finish(true);
            // timeout
            setTimeout(()=>finish(false), timeoutMs);
        });
    }

    // Попытка переключить скрытое видео в Picture-in-Picture.
    // Если параметр `userGesture` = true — вызов произошёл из пользовательского события.
    async function attemptEnterPiP(userGesture = false) {
        if (!active || !active.video) { L('attemptEnterPiP: no active.video'); return false; }
        const video = active.video;
        try {
            L('attemptEnterPiP: video.readyState=', video.readyState, 'paused=', video.paused, 'srcObjectTracks=', (video.srcObject && video.srcObject.getTracks ? video.srcObject.getTracks().length : 0));
        } catch(e){ L('attemptEnterPiP: error reading video state', e); }

        // Не пытаемся удалять элементы управления PiP — это контролируется браузером.
        // Оставляем video скрытым и даём браузеру управлять PiP UI.

        // Сначала пробуем запустить воспроизведение (в некоторых браузерах это требуется)
        try {
            const p = video.play();
            if (p && typeof p.then === 'function') {
                await p.catch(e=>{ L('video.play() promise rejected in attemptEnterPiP', e); });
            }
        } catch(e) { L('video.play() threw in attemptEnterPiP', e); }

        // Также ждём событие 'playing' в течение короткого времени, чтобы повысить шансы на успешный PiP
        const played = await waitForPlay(video, 2500);
        L('attemptEnterPiP: waitForPlay ->', played);

        if (!document.pictureInPictureEnabled) L('attemptEnterPiP: pictureInPictureEnabled false');
        if (!video.requestPictureInPicture) L('attemptEnterPiP: video.requestPictureInPicture not available');

        try {
            // Запросить PiP — браузер всё равно может отклонить при отсутствии жеста
            await video.requestPictureInPicture();
            L('attemptEnterPiP: requestPictureInPicture succeeded');
            try { if (active) active.inPiP = true; } catch(e){}
            // Навесим обработчик закрытия PiP — не делаем полную очистку, сохраняем пайплайн для повторного входа
            try {
                video.addEventListener('leavepictureinpicture', ()=>{
                    L('leavepictureinpicture event — PiP closed by user; keeping pipeline alive for retries');
                    try { if (active) active.inPiP = false; } catch(e){}
                }, { once: true });
            } catch(e) { L('failed to attach leavepictureinpicture listener', e); }
            return true;
        } catch (err) {
            E('attemptEnterPiP: requestPictureInPicture failed:', err, 'userGesture=', !!userGesture);
            return false;
        }
    }

    async function startPipelineFromStreamUrl(streamUrl, fps = FPS, pageImg = null) {
        if (active && active.pageImg && pageImg && active.pageImg === pageImg) {
            L('Already active for this element — skipping start');
            return;
        }
        if (active) {
            L('Different active pipeline exists — cleaning it up before starting new one');
            cleanupActive();
        }

        L('Starting pipeline for', streamUrl);

        // Построить прямой URL потока из src изображения страницы, если это возможно.
        let finalStreamUrl = streamUrl;
        if (pageImg && pageImg.src) {
            try {
                const p = new URL(pageImg.src, location.href);
                finalStreamUrl = `${p.protocol}//${p.hostname}:4409/webcam/?action=stream`;
            } catch(e) {
                // fallback to provided streamUrl
                finalStreamUrl = streamUrl;
            }
        }

        const w = 640;
        const h = 480;
        const canvas = document.createElement('canvas');
        canvas.width = w; canvas.height = h;
        const ctx = canvas.getContext('2d');

        // Создаём объект Image для потока (MJPEG или похожий) и будем рисовать кадры на canvas
        const streamImg = new Image();
        // НЕ устанавливаем crossOrigin — камера локальная и не отдаёт CORS заголовки
        streamImg.src = finalStreamUrl;

        const intervalMs = Math.max(50, Math.round(1000 / fps));
        function drawFrame() {
            try { ctx.drawImage(streamImg, 0, 0, canvas.width, canvas.height); }
            catch (err) { /* may fail until first frame arrives */ }
        }
        drawFrame();
        const timerId = setInterval(drawFrame, intervalMs);

        let stream;
        try { stream = canvas.captureStream(fps); }
        catch (err) { clearInterval(timerId); E('canvas.captureStream failed:', err); return; }

        const video = document.createElement('video');
        video.style.display = 'none';
        video.autoplay = true;
        video.muted = true;
        // Убедиться, что на элементе video нет нативных контролов
        try { video.controls = false; video.removeAttribute && video.removeAttribute('controls'); } catch(e){}
        // Подсказка для iOS/webview: разрешить inline-воспроизведение
        try { video.playsInline = true; } catch(e){}
        video.srcObject = stream;
        document.body.appendChild(video);

        // Регистрируем состояние пайплайна, чтобы другие обработчики (оверлей, visibilitychange) могли управлять им
        try {
            active = { pageImg, streamImg, canvas, ctx, timerId, video, streamTracks: (stream && stream.getTracks ? stream.getTracks() : []) };
        } catch(e) { L('failed to set active state', e); }

        try {
            await video.play().catch(e=>{ L('video.play() rejected:', e); });
            L('video.play() resolved; readyState=', video.readyState, 'paused=', video.paused);
        } catch(e) {
            L('video.play() failed or was rejected; PiP may still be attempted', e);
        }

        if (document.hidden) {
            L('document.hidden true at pipeline start -> attempting PiP via attemptEnterPiP');
            try {
                const ok = await attemptEnterPiP(false);
                L('attemptEnterPiP result:', ok);
                if (!ok) try { showGestureOverlay(); } catch(e){ L('showGestureOverlay failed', e); }
            } catch(e) { L('attemptEnterPiP threw', e); }
        } else {
            L('Tab is visible — delaying automatic PiP until page is hidden');
        }
    }

    // Создаём прозрачный полноэкранный оверлей, который ловит один пользовательский клик (жест)
    function showGestureOverlay() {
        if (!document.body) return;
        // если оверлей уже присутствует — ничего не делаем
        if (document.getElementById('pip-gesture-overlay')) return;
        const overlay = document.createElement('div');
        overlay.id = 'pip-gesture-overlay';
        // полностью прозрачный, но улавливает клики
        Object.assign(overlay.style, {
            position: 'fixed', left: '0', top: '0', right: '0', bottom: '0',
            zIndex: 2147483647, background: 'rgba(0,0,0,0)', cursor: 'pointer'
        });
        // Доступность: невидимая подсказка для пользователей клавиатуры
        overlay.setAttribute('role', 'button');
        overlay.setAttribute('aria-label', 'Click to enable Picture-in-Picture');

        const onClick = async (ev) => {
            try { overlay.removeEventListener('click', onClick); overlay.remove(); } catch(e){}
            L('User gesture received via overlay');
            if (!active || !active.video) {
                L('No active pipeline when gesture occurred');
                return;
            }
            try {
                const ok = await attemptEnterPiP(true);
                if (ok) L('Entered Picture-in-Picture after user gesture');
                else L('attemptEnterPiP (gesture) returned false');
            } catch (e) {
                E('attemptEnterPiP threw', e);
            }
        };

        overlay.addEventListener('click', onClick, { once: true });
        // также слушаем нажатия клавиш (Enter/Space) для доступности
        const onKey = (e) => {
            if (e.key === 'Enter' || e.key === ' ') onClick();
        };
        overlay.addEventListener('keydown', onKey, { once: true });

        document.body.appendChild(overlay);
        // фокусируем, чтобы пользователи клавиатуры могли нажать Enter
        overlay.tabIndex = -1;
        try { overlay.focus(); } catch(e){}
        L('Gesture overlay added — waiting for click');
    }

    // старт: берем host:port прямо из URL страницы и запускаем пайплайн
    (function bootstrap(){
        const streamUrl = buildStreamUrlFromLocation();
        if (streamUrl) {
            L('Using page-derived stream URL:', streamUrl);
            startPipelineFromStreamUrl(streamUrl, FPS, null).catch(e=>L('startPipelineFromStreamUrl failed', e));
        } else {
            L('Could not derive stream URL from page location');
        }

        // Учитываем сценарии Vite/HMR: страница может полностью перезагрузиться — следим за load
        window.addEventListener('load', ()=>{
            try {
                const nowUrl = buildStreamUrlFromLocation();
                if (nowUrl) {
                    L('load event — restarting pipeline with', nowUrl);
                    startPipelineFromStreamUrl(nowUrl, FPS, null).catch(e=>L('startPipelineFromStreamUrl failed on load', e));
                }
            } catch(e) { L('load handler error', e); }
        });

        // Обработка изменения видимости: вход в PiP при скрытии, (опциональный) выход при видимости
        document.addEventListener('visibilitychange', async ()=>{
            try {
                if (document.hidden) {
                    if (active && !document.pictureInPictureElement) {
                        const now = Date.now();
                        if (now - lastAttemptTime > ATTEMPT_COOLDOWN_MS) {
                            L('visibilitychange hidden -> attempting PiP (retry)');
                            lastAttemptTime = now;
                            try { const ok = await attemptEnterPiP(false); L('attemptEnterPiP (visibilityhidden) ->', ok); } catch(e){ L('retry attemptEnterPiP exception', e); }
                        } else {
                            L('visibilitychange hidden -> skipping attempt (cooldown)');
                        }
                    }
                } else {
                    // page became visible
                    if (EXIT_ON_VISIBLE) {
                        if (document.pictureInPictureElement) {
                            L('visibilitychange visible -> exiting PiP (EXIT_ON_VISIBLE=true)');
                            try { await document.exitPictureInPicture().catch(e=>L('exitPictureInPicture failed', e)); } catch(e){ L('exitPictureInPicture exception', e); }
                        }
                    } else {
                        L('visibilitychange visible -> keeping PiP open (EXIT_ON_VISIBLE=false)');
                    }
                }
            } catch(e) { L('visibilitychange handler error', e); }
        });

    })();

})();