Просмотр исходного кода

Add WebRTC screen sharing support to Arozcast receiver (#240)

Alan Yeung 1 неделя назад
Родитель
Сommit
bcb3fd6048
2 измененных файлов с 575 добавлено и 6 удалено
  1. 139 6
      src/web/Arozcast/index.html
  2. 436 0
      src/web/Arozcast/screenshare.html

+ 139 - 6
src/web/Arozcast/index.html

@@ -146,6 +146,13 @@
             object-fit: contain;
         }
 
+        /* ── Screen share mode (WebRTC) ─────────────────────────────── */
+        #acScreen {
+            position: absolute; inset: 0; z-index: 9;
+            width: 100%; height: 100%; object-fit: contain;
+            background: #000;
+        }
+
         /* ── Hidden audio ───────────────────────────────────────────── */
         #acAudio { display: none; }
 
@@ -287,7 +294,9 @@
         </div>
 
         <div class="waiting-hint" x-show="senderState !== 'disconnected'">
-            Enter this code in Musicify on your phone or another device to start casting.
+            Enter this code in Musicify on your phone or another device to start casting,
+            or use <a href="screenshare.html" target="_blank" style="color:var(--accent);text-decoration:underline;text-underline-offset:3px;">Screen Share</a>
+            to stream your desktop to this display.
         </div>
 
         <div class="disconnected-notice" x-show="senderState === 'disconnected'">
@@ -331,6 +340,11 @@
         <img :src="photoSrc" alt="Photo">
     </div>
 
+    <!-- ── Screen share mode (WebRTC) ───────────────────────────── -->
+    <video id="acScreen" x-show="currentTrack && mediaType === 'screen'"
+           playsinline autoplay>
+    </video>
+
     <!-- Hidden audio element for audio mode -->
     <audio id="acAudio"
            @timeupdate="onAudioTimeUpdate()"
@@ -422,6 +436,10 @@ function arozcastApp() {
         toastVisible: false,
         _toastTimer: null,
 
+        // Screen share (WebRTC)
+        _pc: null,
+        _iceQueue: [],
+
         get progressPct() {
             if (!this.duration) return 0;
             return Math.min((this.currentTime / this.duration) * 100, 100);
@@ -429,8 +447,9 @@ function arozcastApp() {
 
         // ── Init ─────────────────────────────────────────────────────
         async init() {
-            this._audio = document.getElementById('acAudio');
-            this._video = document.getElementById('acVideo');
+            this._audio  = document.getElementById('acAudio');
+            this._video  = document.getElementById('acVideo');
+            this._screen = document.getElementById('acScreen');
 
             // Fullscreen change listener
             const self = this;
@@ -571,6 +590,30 @@ function arozcastApp() {
                 case 'peer.heartbeat':
                     this._touchPeer();
                     break;
+                case 'screen.start':
+                    this._touchPeer();
+                    this._prepareScreen();
+                    break;
+                case 'webrtc.offer':
+                    this._touchPeer();
+                    this._handleOffer(msg.payload);
+                    break;
+                case 'webrtc.ice':
+                    this._touchPeer();
+                    if (this._pc) {
+                        if (this._pc.remoteDescription) {
+                            this._pc.addIceCandidate(msg.payload.candidate).catch(() => {});
+                        } else {
+                            this._iceQueue.push(msg.payload.candidate);
+                        }
+                    } else {
+                        this._iceQueue.push(msg.payload.candidate);
+                    }
+                    break;
+                case 'screen.stop':
+                    this._touchPeer();
+                    this._stopScreen();
+                    break;
                 // 'status.update' and 'media.ended' are sent BY this receiver —
                 // ignore silently if received (loopback via duplicate connection).
             }
@@ -703,6 +746,95 @@ function arozcastApp() {
             this.duration = 0;
         },
 
+        // ── Screen share (WebRTC) ─────────────────────────────────────
+        _prepareScreen() {
+            if (this._pc) { this._pc.close(); this._pc = null; }
+            this._iceQueue = [];
+            this._audio.pause();
+            this._audio.removeAttribute('src');
+            this._video.pause();
+            this._video.removeAttribute('src');
+            this.currentTrack = { name: 'Screen Share', type: 'screen' };
+            this.mediaType    = 'screen';
+            this.isPlaying    = false;
+            this.currentTime  = 0;
+            this.duration     = 0;
+        },
+
+        async _handleOffer(payload) {
+            const self = this;
+            if (this._pc) { this._pc.close(); }
+            this._iceQueue = [];
+
+            const pc = new RTCPeerConnection({
+                iceServers: [
+                    { urls: 'stun:stun.l.google.com:19302' },
+                    { urls: 'stun:stun1.l.google.com:19302' },
+                ]
+            });
+            // Assign early so arriving webrtc.ice messages are queued correctly
+            this._pc = pc;
+
+            pc.onicecandidate = (e) => {
+                if (e.candidate && self.ws && self.ws.readyState === WebSocket.OPEN) {
+                    self.ws.send(JSON.stringify({
+                        topic: 'webrtc.ice',
+                        payload: { candidate: e.candidate.toJSON() }
+                    }));
+                }
+            };
+
+            pc.ontrack = (e) => {
+                if (!self._screen.srcObject) {
+                    self._screen.srcObject = e.streams[0];
+                    self._screen.muted = self.isMuted;
+                    self._screen.play().catch(() => {});
+                    self._showToast('Screen share started');
+                }
+            };
+
+            pc.onconnectionstatechange = () => {
+                if (pc.connectionState === 'disconnected' || pc.connectionState === 'failed') {
+                    self._stopScreen();
+                }
+            };
+
+            try {
+                await pc.setRemoteDescription({ type: payload.type, sdp: payload.sdp });
+
+                // Drain ICE candidates that arrived before setRemoteDescription completed
+                for (const c of this._iceQueue) {
+                    await pc.addIceCandidate(c).catch(() => {});
+                }
+                this._iceQueue = [];
+
+                const answer = await pc.createAnswer();
+                await pc.setLocalDescription(answer);
+
+                if (self.ws && self.ws.readyState === WebSocket.OPEN) {
+                    self.ws.send(JSON.stringify({
+                        topic: 'webrtc.answer',
+                        payload: { sdp: answer.sdp, type: answer.type }
+                    }));
+                }
+            } catch (e) {
+                self._showToast('Screen share setup failed');
+                self._stopScreen();
+            }
+        },
+
+        _stopScreen() {
+            if (this._pc) { this._pc.close(); this._pc = null; }
+            this._iceQueue = [];
+            if (this._screen) { this._screen.srcObject = null; }
+            this.currentTrack = null;
+            this.mediaType    = 'audio';
+            this.isPlaying    = false;
+            this.currentTime  = 0;
+            this.duration     = 0;
+            this._showToast('Screen share ended');
+        },
+
         // ── Audio/Video event handlers ────────────────────────────────
         onAudioTimeUpdate() {
             this.currentTime = this._audio.currentTime;
@@ -733,7 +865,7 @@ function arozcastApp() {
             clearTimeout(this._toolbarTimer);
             const self = this;
             this._toolbarTimer = setTimeout(() => {
-                if (self.currentTrack && self.mediaType === 'video') {
+                if (self.currentTrack && (self.mediaType === 'video' || self.mediaType === 'screen')) {
                     self.toolbarHidden = true;
                 }
             }, 3000);
@@ -749,8 +881,9 @@ function arozcastApp() {
 
         toggleMute() {
             this.isMuted = !this.isMuted;
-            this._audio.muted = this.isMuted;
-            this._video.muted = this.isMuted;
+            this._audio.muted  = this.isMuted;
+            this._video.muted  = this.isMuted;
+            this._screen.muted = this.isMuted;
         },
 
         // ── Toast ─────────────────────────────────────────────────────

+ 436 - 0
src/web/Arozcast/screenshare.html

@@ -0,0 +1,436 @@
+<!doctype html>
+<html lang="en">
+<head>
+    <meta charset="utf-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1">
+    <title>Screen Share — Arozcast</title>
+    <script src="../script/jquery.min.js"></script>
+    <script src="../script/ao_module.js"></script>
+    <style>
+        *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
+        :root {
+            --bg:     #0a0a0f;
+            --card:   rgba(255,255,255,.05);
+            --border: rgba(255,255,255,.1);
+            --accent: #b0b0b0;
+            --text:   #f0f0f5;
+            --text2:  #9090a8;
+            --green:  #22c55e;
+            --red:    #ef4444;
+            --blue:   #3b82f6;
+        }
+        html, body {
+            min-height: 100vh;
+            background: var(--bg);
+            color: var(--text);
+            font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
+            display: flex;
+            align-items: center;
+            justify-content: center;
+            padding: 24px;
+        }
+        .container { width: 100%; max-width: 480px; }
+
+        .header {
+            display: flex; align-items: center; gap: 14px;
+            margin-bottom: 32px;
+        }
+        .brand-icon { width: 42px; height: 42px; }
+        .brand-name { font-size: 22px; font-weight: 800; letter-spacing: -0.5px; }
+        .brand-sub  { font-size: 12px; color: var(--text2); margin-top: 2px; }
+
+        .card {
+            background: var(--card);
+            border: 1px solid var(--border);
+            border-radius: 16px;
+            padding: 24px;
+            margin-bottom: 16px;
+        }
+        .card-title {
+            font-size: 11px; text-transform: uppercase; letter-spacing: .14em;
+            color: var(--text2); margin-bottom: 16px;
+        }
+
+        .code-row { display: flex; gap: 10px; }
+        .code-input {
+            flex: 1;
+            background: rgba(255,255,255,.07);
+            border: 1.5px solid var(--border);
+            border-radius: 10px;
+            padding: 12px 16px;
+            color: var(--text);
+            font-size: 28px; font-weight: 700; letter-spacing: .25em;
+            text-align: center; outline: none;
+            transition: border-color .15s;
+        }
+        .code-input:focus { border-color: rgba(255,255,255,.3); }
+        .code-input::placeholder { color: var(--text2); font-size: 16px; letter-spacing: .1em; }
+
+        .btn {
+            padding: 12px 20px;
+            border-radius: 10px; border: none; cursor: pointer;
+            font-size: 14px; font-weight: 600;
+            transition: opacity .15s, transform .1s;
+        }
+        .btn:active { transform: scale(.97); }
+        .btn:disabled { opacity: 0.4; cursor: default; transform: none; }
+        .btn-primary { background: var(--accent); color: #000; }
+        .btn-green   { background: var(--green);  color: #000; }
+        .btn-red     { background: var(--red);    color: #fff; }
+        .btn-ghost   { background: rgba(255,255,255,.08); color: var(--text); }
+
+        .status-row {
+            display: flex; align-items: center; gap: 8px;
+            font-size: 13px; color: var(--text2);
+        }
+        .dot {
+            width: 8px; height: 8px; border-radius: 50%;
+            background: var(--text2); flex-shrink: 0;
+        }
+        .dot.green { background: var(--green); box-shadow: 0 0 6px var(--green); }
+        .dot.blue  { background: var(--blue);  box-shadow: 0 0 6px var(--blue);  }
+        .dot.red   { background: var(--red); }
+
+        #localPreview {
+            width: 100%; border-radius: 10px; background: #000;
+            aspect-ratio: 16/9; object-fit: contain;
+            display: none; margin-top: 16px;
+        }
+        #localPreview.visible { display: block; }
+
+        .controls-row { display: flex; gap: 10px; margin-top: 16px; }
+        .controls-row .btn { flex: 1; }
+
+        .error-msg {
+            font-size: 13px; color: #fca5a5;
+            background: rgba(239,68,68,.08);
+            border: 1px solid rgba(239,68,68,.2);
+            border-radius: 8px; padding: 10px 14px;
+            margin-top: 12px; display: none;
+        }
+        .error-msg.visible { display: block; }
+
+        .hint {
+            font-size: 12px; color: var(--text2);
+            line-height: 1.65; margin-top: 14px;
+        }
+    </style>
+</head>
+<body>
+<div class="container">
+
+    <div class="header">
+        <svg class="brand-icon" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
+            <rect width="48" height="48" rx="12" fill="#b0b0b0"/>
+            <path d="M34 14H14a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h4v-2h-4V16h20v4h2v-4a2 2 0 0 0-2-2Z" fill="white"/>
+            <path d="M10 36v-4a6 6 0 0 1 6 6h-4a2 2 0 0 0-2-2Zm0 4h4a10 10 0 0 0-4-8v4a6 6 0 0 1 4 4Zm0 0h-2v-2h2v2Z" fill="white" fill-opacity="0.8"/>
+            <path d="M22 38h-4a14 14 0 0 0-14-14v4a10 10 0 0 1 10 10h4a14 14 0 0 0-14-14v-4a18 18 0 0 1 18 18Z" fill="white" fill-opacity="0.5"/>
+        </svg>
+        <div>
+            <div class="brand-name">Arozcast</div>
+            <div class="brand-sub">Screen Share</div>
+        </div>
+    </div>
+
+    <!-- Step 1: connect to room -->
+    <div class="card" id="connectCard">
+        <div class="card-title">Step 1 — Connect to receiver</div>
+        <div class="code-row">
+            <input class="code-input" id="codeInput" type="text" inputmode="numeric"
+                   maxlength="4" placeholder="0000" autocomplete="off">
+            <button class="btn btn-primary" id="connectBtn" onclick="connectRoom()">Connect</button>
+        </div>
+        <div style="margin-top:12px;">
+            <div class="status-row">
+                <div class="dot" id="connDot"></div>
+                <span id="connStatus">Enter the 4-digit code shown on the Arozcast receiver</span>
+            </div>
+        </div>
+        <div class="error-msg" id="connectError"></div>
+    </div>
+
+    <!-- Step 2: share controls (shown after connecting) -->
+    <div class="card" id="shareCard" style="display:none;">
+        <div class="card-title">Step 2 — Share your screen</div>
+        <div class="status-row">
+            <div class="dot green" id="shareDot"></div>
+            <span id="shareStatus">Connected</span>
+        </div>
+
+        <!-- Local preview (shown while sharing) -->
+        <video id="localPreview" muted playsinline autoplay></video>
+
+        <div class="controls-row">
+            <button class="btn btn-green"  id="startBtn"      onclick="startShare()">Start sharing</button>
+            <button class="btn btn-red"    id="stopBtn"       onclick="stopShare()"  style="display:none;" disabled>Stop sharing</button>
+            <button class="btn btn-ghost"  id="disconnectBtn" onclick="disconnect()">Disconnect</button>
+        </div>
+        <div class="error-msg" id="shareError"></div>
+        <p class="hint">
+            Your screen will be streamed live to the Arozcast receiver via a direct peer-to-peer connection.
+            System audio is included when your browser and OS support it.
+        </p>
+    </div>
+
+</div>
+<script>
+// ── WebRTC configuration ───────────────────────────────────────────────────
+const RTC_CONFIG = {
+    iceServers: [
+        { urls: 'stun:stun.l.google.com:19302' },
+        { urls: 'stun:stun1.l.google.com:19302' },
+    ]
+};
+
+// ── State ─────────────────────────────────────────────────────────────────
+let ws               = null;
+let pc               = null;   // RTCPeerConnection
+let stream           = null;   // MediaStream from getDisplayMedia
+let currentCode      = null;
+let heartbeatTimer   = null;
+let isSharing        = false;
+let iceQueue         = [];     // ICE candidates buffered before remote description is set
+
+// ── DOM refs ──────────────────────────────────────────────────────────────
+const codeInput       = document.getElementById('codeInput');
+const connectBtn      = document.getElementById('connectBtn');
+const connDot         = document.getElementById('connDot');
+const connStatus      = document.getElementById('connStatus');
+const connectError    = document.getElementById('connectError');
+const connectCard     = document.getElementById('connectCard');
+const shareCard       = document.getElementById('shareCard');
+const shareStatus     = document.getElementById('shareStatus');
+const shareDot        = document.getElementById('shareDot');
+const startBtn        = document.getElementById('startBtn');
+const stopBtn         = document.getElementById('stopBtn');
+const disconnectBtn   = document.getElementById('disconnectBtn');
+const shareError      = document.getElementById('shareError');
+const localPreview    = document.getElementById('localPreview');
+
+// ── Helpers ───────────────────────────────────────────────────────────────
+function showError(el, msg) { el.textContent = msg; el.classList.add('visible'); }
+function clearError(el)     { el.classList.remove('visible'); }
+
+function setConnStatus(dotClass, html) {
+    connDot.className = 'dot' + (dotClass ? ' ' + dotClass : '');
+    connStatus.innerHTML = html;
+}
+function setShareStatus(dotClass, html) {
+    shareDot.className = 'dot' + (dotClass ? ' ' + dotClass : '');
+    shareStatus.innerHTML = html;
+}
+
+function wsSend(topic, payload) {
+    if (ws && ws.readyState === WebSocket.OPEN) {
+        ws.send(JSON.stringify({ topic, payload: payload || {} }));
+    }
+}
+
+// ── Room connection ───────────────────────────────────────────────────────
+async function connectRoom() {
+    const code = codeInput.value.trim();
+    if (!/^\d{4}$/.test(code)) {
+        showError(connectError, 'Please enter a valid 4-digit room code.');
+        return;
+    }
+    clearError(connectError);
+    connectBtn.disabled = true;
+    codeInput.disabled  = true;
+    setConnStatus('', 'Checking room…');
+
+    const root = (typeof ao_root !== 'undefined' && ao_root) ? ao_root : '/';
+
+    try {
+        const res  = await fetch(root + 'api/arozcast/ping?code=' + code);
+        const data = await res.json();
+        if (!data.exists) {
+            showError(connectError, 'Room ' + code + ' not found. Check the code on the Arozcast receiver.');
+            connectBtn.disabled = false;
+            codeInput.disabled  = false;
+            setConnStatus('', 'Enter the 4-digit code shown on the Arozcast receiver');
+            return;
+        }
+    } catch (e) {
+        showError(connectError, 'Could not reach the server. Check your connection.');
+        connectBtn.disabled = false;
+        codeInput.disabled  = false;
+        return;
+    }
+
+    setConnStatus('', 'Connecting…');
+
+    const wsUrl = new URL(root + 'api/arozcast/ws?code=' + code, window.location.href);
+    wsUrl.protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
+    ws = new WebSocket(wsUrl.toString());
+
+    ws.onopen = () => {
+        currentCode = code;
+        wsSend('peer.hello', {});
+        heartbeatTimer = setInterval(() => wsSend('peer.heartbeat', {}), 5000);
+
+        connectCard.style.display = 'none';
+        shareCard.style.display   = 'block';
+        setShareStatus('green', 'Connected to room <strong>' + code + '</strong>');
+    };
+
+    ws.onmessage = (evt) => {
+        let msg;
+        try { msg = JSON.parse(evt.data); } catch (_) { return; }
+        handleMessage(msg);
+    };
+
+    ws.onclose = () => {
+        clearInterval(heartbeatTimer);
+        heartbeatTimer = null;
+        if (isSharing) _cleanupRTC();
+        if (currentCode) {
+            setShareStatus('red',
+                'Disconnected. <a href="" onclick="location.reload();return false;" style="color:#93c5fd;">Reconnect</a>');
+        }
+    };
+
+    ws.onerror = () => {};
+}
+
+// ── Incoming message handler ──────────────────────────────────────────────
+async function handleMessage(msg) {
+    switch (msg.topic) {
+        case 'webrtc.answer':
+            if (pc && pc.signalingState === 'have-local-offer') {
+                try {
+                    await pc.setRemoteDescription({ type: 'answer', sdp: msg.payload.sdp });
+                    for (const c of iceQueue) await pc.addIceCandidate(c).catch(() => {});
+                    iceQueue = [];
+                } catch (e) {
+                    showError(shareError, 'WebRTC negotiation failed: ' + e.message);
+                }
+            }
+            break;
+
+        case 'webrtc.ice':
+            if (pc) {
+                if (pc.remoteDescription) {
+                    pc.addIceCandidate(msg.payload.candidate).catch(() => {});
+                } else {
+                    iceQueue.push(msg.payload.candidate);
+                }
+            }
+            break;
+
+        case 'room.closed':
+            clearInterval(heartbeatTimer);
+            if (isSharing) _cleanupRTC();
+            setShareStatus('red', 'Room was closed by the server.');
+            break;
+    }
+}
+
+// ── Screen sharing ────────────────────────────────────────────────────────
+async function startShare() {
+    clearError(shareError);
+    startBtn.disabled = true;
+
+    try {
+        stream = await navigator.mediaDevices.getDisplayMedia({ video: true, audio: true });
+    } catch (e) {
+        showError(shareError,
+            e.name === 'NotAllowedError'
+                ? 'Screen capture permission denied.'
+                : 'Could not start screen capture: ' + e.message);
+        startBtn.disabled = false;
+        return;
+    }
+
+    localPreview.srcObject = stream;
+    localPreview.classList.add('visible');
+
+    iceQueue = [];
+    pc = new RTCPeerConnection(RTC_CONFIG);
+
+    pc.onicecandidate = (e) => {
+        if (e.candidate) wsSend('webrtc.ice', { candidate: e.candidate.toJSON() });
+    };
+
+    pc.onconnectionstatechange = () => {
+        if (!pc) return;
+        if (pc.connectionState === 'connected') {
+            setShareStatus('blue', 'Streaming to room <strong>' + currentCode + '</strong>');
+        } else if (pc.connectionState === 'disconnected' || pc.connectionState === 'failed') {
+            showError(shareError, 'Peer connection lost. The receiver may have closed.');
+            stopShare(true);
+        }
+    };
+
+    for (const track of stream.getTracks()) {
+        pc.addTrack(track, stream);
+    }
+
+    // Handle user pressing the browser's native "Stop sharing" button
+    stream.getVideoTracks()[0].onended = () => stopShare();
+
+    wsSend('screen.start', {});
+
+    try {
+        const offer = await pc.createOffer();
+        await pc.setLocalDescription(offer);
+        wsSend('webrtc.offer', { sdp: offer.sdp, type: offer.type });
+    } catch (e) {
+        showError(shareError, 'Failed to create WebRTC offer: ' + e.message);
+        stopShare(true);
+        return;
+    }
+
+    isSharing = true;
+    startBtn.style.display      = 'none';
+    stopBtn.style.display       = 'block';
+    stopBtn.disabled            = false;
+    disconnectBtn.disabled      = true;
+    setShareStatus('blue', 'Connecting to receiver… room <strong>' + currentCode + '</strong>');
+}
+
+function stopShare(silent) {
+    _cleanupRTC();
+    if (!silent) wsSend('screen.stop', {});
+
+    startBtn.style.display  = 'block';
+    startBtn.disabled       = false;
+    stopBtn.style.display   = 'none';
+    stopBtn.disabled        = true;
+    disconnectBtn.disabled  = false;
+    setShareStatus('green', 'Connected to room <strong>' + currentCode + '</strong>');
+}
+
+function _cleanupRTC() {
+    isSharing = false;
+    if (stream) { stream.getTracks().forEach(t => t.stop()); stream = null; }
+    if (pc)     { pc.close(); pc = null; }
+    localPreview.srcObject = null;
+    localPreview.classList.remove('visible');
+}
+
+function disconnect() {
+    if (isSharing) stopShare();
+    clearInterval(heartbeatTimer);
+    heartbeatTimer = null;
+    currentCode    = null;
+    if (ws) { ws.onclose = null; ws.close(); ws = null; }
+
+    connectCard.style.display = 'block';
+    shareCard.style.display   = 'none';
+    connectBtn.disabled = false;
+    codeInput.disabled  = false;
+    codeInput.value     = '';
+    setConnStatus('', 'Enter the 4-digit code shown on the Arozcast receiver');
+}
+
+// ── Keyboard shortcut ────────────────────────────────────────────────────
+codeInput.addEventListener('keydown', (e) => { if (e.key === 'Enter') connectRoom(); });
+
+// ── Clean up on unload ────────────────────────────────────────────────────
+window.addEventListener('beforeunload', () => {
+    if (isSharing) _cleanupRTC();
+    if (ws) { ws.onclose = null; ws.close(); }
+});
+</script>
+</body>
+</html>