VirtualCam

This commit is contained in:
2026-08-19 03:35:48 +02:00
commit 4993a00dc9
6 changed files with 507 additions and 0 deletions
+231
View File
@@ -0,0 +1,231 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>Phone as Webcam (PWA)</title>
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1" />
<link rel="manifest" href="/manifest.json" />
<style>
html,body {
height: 100%;
margin: 0;
padding: 0;
background: #000; /* black background per request */
color: #fff;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
font-family: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial;
}
.container {
padding: 1rem;
max-width: 640px;
margin: 0 auto;
}
h1 { font-size: 1.1rem; margin: 0 0 0.5rem 0; color: #fff; }
video { width: 100%; max-width: 640px; background: #000; display: block; border-radius: 6px; }
.controls { margin-top: 0.5rem; display:flex; gap:0.5rem; flex-wrap:wrap; }
button {
background: rgba(255,255,255,0.08);
color: #fff;
border: 1px solid rgba(255,255,255,0.06);
padding: 0.5rem 0.8rem;
border-radius: 6px;
cursor: pointer;
min-width: 120px;
}
button:disabled { opacity: 0.4; cursor: default; }
#status { margin-top: 0.6rem; color: #bbb; font-size: 0.92rem; }
/* overlay used to "turn screen off" -- pure black */
#offOverlay {
position: fixed;
inset: 0;
background: #000;
z-index: 9999;
display: none;
touch-action: manipulation;
}
/* when overlay is active, hide UI but keep it accessible via tap */
.ui-hidden .container { visibility: hidden; } /* keep layout but visually hidden */
.ui-hidden #offOverlay { display: block; }
/* small helper so when UI hidden, status bar is still black on some devices */
meta[name=theme-color] { }
</style>
</head>
<body>
<div id="offOverlay" role="button" aria-label="Tap to restore"></div>
<div class="container" id="app">
<h1>Use this device as a webcam</h1>
<video id="local" autoplay playsinline muted></video>
<div class="controls" id="controls">
<button id="start">Start streaming</button>
<button id="stop" disabled>Stop</button>
<button id="switchCam" disabled>Switch camera</button>
<button id="turnOff">Turn screen off</button>
</div>
<p id="status">Not connected</p>
</div>
<script>
// Basic PWA service worker registration
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js').catch(e => {
console.warn('Service worker registration failed:', e);
});
}
const startButton = document.getElementById('start');
const stopButton = document.getElementById('stop');
const switchButton = document.getElementById('switchCam');
const turnOffButton = document.getElementById('turnOff');
const localVideo = document.getElementById('local');
const status = document.getElementById('status');
const offOverlay = document.getElementById('offOverlay');
const appEl = document.getElementById('app');
let pc = null;
let localStream = null;
let videoSender = null;
let currentFacing = 'user'; // 'user' or 'environment'
async function getCameraStream(facing) {
const constraints = {
video: {
facingMode: { ideal: facing }, // 'user' or 'environment'
width: { ideal: 1920 },
height: { ideal: 1080 },
},
audio: false
};
try {
return await navigator.mediaDevices.getUserMedia(constraints);
} catch (err) {
// fallback: try without facingMode constraints
console.warn('getUserMedia with facingMode failed, trying generic video:', err);
return await navigator.mediaDevices.getUserMedia({ video: true, audio: false });
}
}
startButton.onclick = async () => {
startButton.disabled = true;
status.textContent = 'Getting camera...';
try {
localStream = await getCameraStream(currentFacing);
} catch (e) {
status.textContent = 'getUserMedia error: ' + e;
startButton.disabled = false;
return;
}
localVideo.srcObject = localStream;
pc = new RTCPeerConnection();
pc.oniceconnectionstatechange = () => {
console.log('ICE', pc.iceConnectionState);
if (pc.iceConnectionState === 'failed') pc.close();
};
// add tracks and keep video sender reference so we can replaceTrack later
localStream.getTracks().forEach(track => {
const sender = pc.addTrack(track, localStream);
if (track.kind === 'video') videoSender = sender;
});
status.textContent = 'Creating offer...';
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
status.textContent = 'Sending offer to server...';
const resp = await fetch('/offer', {
method: 'POST',
body: JSON.stringify(pc.localDescription),
headers: { 'Content-Type': 'application/json' }
});
const answer = await resp.json();
await pc.setRemoteDescription(answer);
status.textContent = 'Streaming to server';
stopButton.disabled = false;
switchButton.disabled = false;
};
stopButton.onclick = () => {
if (localStream) {
localStream.getTracks().forEach(t => t.stop());
localStream = null;
}
if (pc) {
try {
pc.getSenders().forEach(s => { if (s.track) s.track.stop(); });
pc.close();
} catch (e) { console.warn(e); }
pc = null;
}
localVideo.srcObject = null;
status.textContent = 'Stopped';
startButton.disabled = false;
stopButton.disabled = true;
switchButton.disabled = true;
videoSender = null;
};
// Switch camera: obtains new track and replaces the outgoing sender's track
switchButton.onclick = async () => {
if (!pc || !videoSender) {
status.textContent = 'Not streaming';
return;
}
status.textContent = 'Switching camera...';
// toggle facing
currentFacing = currentFacing === 'user' ? 'environment' : 'user';
try {
// get new stream first
const newStream = await getCameraStream(currentFacing);
const newVideoTrack = newStream.getVideoTracks()[0];
// replace the outgoing track first (minimize race)
await videoSender.replaceTrack(newVideoTrack);
// stop old local preview tracks after replacement
if (localStream) localStream.getTracks().forEach(t => t.stop());
// update preview and localStream reference
localStream = newStream;
localVideo.srcObject = newStream;
status.textContent = 'Switched camera';
} catch (e) {
console.error('Switch camera error', e);
status.textContent = 'Switch camera failed: ' + e;
}
};
// Turn screen off: hide UI and show black overlay; tapping overlay restores UI
turnOffButton.onclick = () => {
document.body.classList.add('ui-hidden');
// attempt to dim screen / request fullscreen if user desires later (not automatic)
// overlay is visible and will handle taps
};
// restore UI on any tap on overlay (touchstart/click)
function restoreUI() {
document.body.classList.remove('ui-hidden');
}
offOverlay.addEventListener('click', restoreUI, { passive: true });
offOverlay.addEventListener('touchstart', (e) => { e.preventDefault(); restoreUI(); }, { passive: false });
// Optional: make short tap also act as toggling overlay
// Keep a small safety: long press won't wake up
// No-op for now.
// Show install hint if available (small UX improvement)
window.addEventListener('beforeinstallprompt', (e) => {
// Prevent automatic prompt; we could show a custom UI later
e.preventDefault();
console.log('beforeinstallprompt fired');
// If desired, you could keep 'e' and call e.prompt() when the user chooses to install
});
</script>
</body>
</html>
+21
View File
@@ -0,0 +1,21 @@
{
"name": "Phone as Webcam",
"short_name": "WebCamPhone",
"start_url": "/",
"display": "standalone",
"background_color": "#000000",
"theme_color": "#000000",
"description": "Use your phone as a webcam for Linux via WebRTC -> v4l2loopback",
"icons": [
{
"src": "/static/icon-192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/static/icon-512.png",
"sizes": "512x512",
"type": "image/png"
}
]
}
+45
View File
@@ -0,0 +1,45 @@
// Minimal service worker: cache the app shell so site is quickly available and installable.
const CACHE_NAME = 'phone-webcam-v1';
const FILES_TO_CACHE = [
'/',
'/manifest.json',
'/static/index.html',
'/static/index.html', // duplicated intentionally harmless
'/static/icon-192.png'
];
self.addEventListener('install', (evt) => {
evt.waitUntil(
caches.open(CACHE_NAME).then(cache => cache.addAll(FILES_TO_CACHE))
);
self.skipWaiting();
});
self.addEventListener('activate', (evt) => {
evt.waitUntil(
caches.keys().then(keys => Promise.all(
keys.map(k => { if (k !== CACHE_NAME) return caches.delete(k); })
))
);
self.clients.claim();
});
self.addEventListener('fetch', (evt) => {
// network-first for /offer (dynamic), cache-first for app shell assets
const url = new URL(evt.request.url);
if (url.pathname === '/offer') {
// passthrough to network
evt.respondWith(fetch(evt.request));
return;
}
evt.respondWith(
caches.match(evt.request).then(cached => cached || fetch(evt.request).then(resp => {
// optionally cache dynamic responses here
return resp;
}).catch(() => {
// fallback could be a blank response for image or offline page
return caches.match('/');
}))
);
});