Skip to content

Commit 53b1590

Browse files
authored
Merge pull request #75 from zancheema/issue-54
Virtual Mirror App
2 parents ffa2016 + 0db17db commit 53b1590

File tree

5 files changed

+351
-0
lines changed

5 files changed

+351
-0
lines changed
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
# 🪞 Webcam Mirror — JavaScript Demo
2+
3+
A lightweight web app that uses your device’s **webcam** and flips the video feed **horizontally**, so it behaves just like a real mirror.
4+
Built with plain **HTML**, **CSS**, and **JavaScript** — no frameworks required.
5+
6+
---
7+
8+
## 📸 Features
9+
10+
- 🔁 **Real mirror effect** — flips the camera preview horizontally.
11+
- 🎥 **Camera selection** — choose between multiple cameras (front/back on mobile).
12+
- 🖼️ **Snapshot capture** — take photos that respect the mirror orientation.
13+
- 💾 **Instant download** — download your mirrored photo as a `.png` file.
14+
- ⚙️ **Toggle mirroring** — turn the mirror effect on or off anytime.
15+
- 🌗 **Dark UI** — modern, clean, and responsive design.
16+
17+
---
18+
19+
## 🚀 Live Demo
20+
21+
You can run this app locally by opening the HTML file directly in a browser that supports **`getUserMedia()`** (most modern browsers).
22+
23+
---
24+
25+
## 🧠 How It Works
26+
27+
The app uses the **WebRTC API** (`navigator.mediaDevices.getUserMedia`) to access your webcam stream and display it in a `<video>` element.
28+
CSS is used to flip the live preview horizontally using:
29+
30+
```css
31+
video {
32+
transform: scaleX(-1);
33+
}
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
<!doctype html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="utf-8" />
5+
<meta name="viewport" content="width=device-width,initial-scale=1" />
6+
<title>Webcam Mirror</title>
7+
<link rel="stylesheet" href="styles/styles.css">
8+
</head>
9+
<body>
10+
<div class="app">
11+
<header>
12+
<img src="resources/camera.svg" alt="Camera Icon" width="36" height="36" />
13+
<div>
14+
<h1>Webcam Mirror</h1>
15+
<div class="hint">Uses your webcam and flips the preview horizontally like a real mirror.</div>
16+
</div>
17+
</header>
18+
19+
<div class="controls">
20+
<button id="startBtn">Start Camera</button>
21+
<button id="stopBtn" disabled>Stop Camera</button>
22+
<select id="deviceSelect" aria-label="Choose camera"></select>
23+
<label class="row" style="align-items:center"><input type="checkbox" id="mirrorToggle" checked> Mirror preview</label>
24+
<button id="captureBtn" disabled>Capture Photo</button>
25+
<a id="downloadLink" style="display:none" download="mirror-snapshot.png">Download snapshot</a>
26+
</div>
27+
28+
<div class="stage">
29+
<div class="viewer">
30+
<video id="video" autoplay playsinline muted></video>
31+
</div>
32+
33+
<aside class="sidebar">
34+
<div class="row"><div class="label">Snapshot</div></div>
35+
<canvas id="canvas" width="320" height="240" aria-hidden></canvas>
36+
<div class="row" style="margin-top:8px"><div class="hint">Captured image respects the mirror setting so it looks like a real mirror photo.</div></div>
37+
<div style="height:8px"></div>
38+
<div class="row"><div class="label">Status:</div><div id="status" class="hint">Idle</div></div>
39+
</aside>
40+
</div>
41+
42+
<footer>
43+
Permission to access the camera will be requested. If nothing happens, check your browser permissions or try a different camera from the dropdown.
44+
</footer>
45+
</div>
46+
47+
<script src="scripts/script.js"></script>
48+
</body>
49+
</html>
Lines changed: 15 additions & 0 deletions
Loading
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
const startBtn = document.getElementById('startBtn');
2+
const stopBtn = document.getElementById('stopBtn');
3+
const video = document.getElementById('video');
4+
const deviceSelect = document.getElementById('deviceSelect');
5+
const mirrorToggle = document.getElementById('mirrorToggle');
6+
const captureBtn = document.getElementById('captureBtn');
7+
const canvas = document.getElementById('canvas');
8+
const downloadLink = document.getElementById('downloadLink');
9+
const status = document.getElementById('status');
10+
11+
let stream = null;
12+
13+
function setStatus(s){ status.textContent = s; }
14+
15+
async function enumerateCameras(){
16+
try{
17+
const devices = await navigator.mediaDevices.enumerateDevices();
18+
const cams = devices.filter(d => d.kind === 'videoinput');
19+
deviceSelect.innerHTML = '';
20+
cams.forEach((c, i)=>{
21+
const opt = document.createElement('option');
22+
opt.value = c.deviceId;
23+
opt.text = c.label || `Camera ${i+1}`;
24+
deviceSelect.appendChild(opt);
25+
});
26+
if(cams.length===0) deviceSelect.innerHTML = '<option disabled>No cameras found</option>';
27+
}catch(err){
28+
console.error('Cannot list devices', err);
29+
deviceSelect.innerHTML = '<option disabled>Unable to enumerate devices</option>';
30+
}
31+
}
32+
33+
async function startCamera(deviceId){
34+
stopCamera();
35+
setStatus('Requesting camera...');
36+
const constraints = {
37+
audio: false,
38+
video: {
39+
width: {ideal: 1280},
40+
height: {ideal: 720},
41+
}
42+
};
43+
if(deviceId) constraints.video.deviceId = { exact: deviceId };
44+
try{
45+
stream = await navigator.mediaDevices.getUserMedia(constraints);
46+
video.srcObject = stream;
47+
startBtn.disabled = true;
48+
stopBtn.disabled = false;
49+
captureBtn.disabled = false;
50+
setStatus('Camera started');
51+
await enumerateCameras(); // refresh labels (some browsers only expose labels after permission)
52+
applyMirror();
53+
}catch(err){
54+
console.error('getUserMedia error', err);
55+
setStatus('Camera error: ' + (err.message || err.name));
56+
}
57+
}
58+
59+
function stopCamera(){
60+
if(stream){
61+
stream.getTracks().forEach(t => t.stop());
62+
stream = null;
63+
video.srcObject = null;
64+
startBtn.disabled = false;
65+
stopBtn.disabled = true;
66+
captureBtn.disabled = true;
67+
setStatus('Camera stopped');
68+
}
69+
}
70+
71+
function applyMirror(){
72+
const mirrored = mirrorToggle.checked;
73+
// For the live preview, the easiest and smoothest approach is CSS transform.
74+
// This flips the DOM element visually but does not change the underlying camera frames.
75+
video.style.transform = mirrored ? 'scaleX(-1)' : 'none';
76+
}
77+
78+
function captureSnapshot(){
79+
if(!video || video.readyState < 2) return;
80+
const w = canvas.width = video.videoWidth || 320;
81+
const h = canvas.height = video.videoHeight || 240;
82+
const ctx = canvas.getContext('2d');
83+
84+
ctx.save();
85+
if(mirrorToggle.checked){
86+
// To make the saved image match the mirrored preview, draw the video flipped on the canvas.
87+
ctx.translate(w, 0);
88+
ctx.scale(-1, 1);
89+
}
90+
// draw the video frame to canvas
91+
ctx.drawImage(video, 0, 0, w, h);
92+
ctx.restore();
93+
94+
// Create a download link for the snapshot
95+
canvas.toBlob(blob => {
96+
if(!blob) return;
97+
const url = URL.createObjectURL(blob);
98+
downloadLink.href = url;
99+
downloadLink.style.display = 'inline-block';
100+
downloadLink.textContent = 'Download snapshot';
101+
}, 'image/png');
102+
103+
setStatus('Snapshot captured');
104+
}
105+
106+
// Wire up UI
107+
startBtn.addEventListener('click', async ()=>{
108+
const selected = deviceSelect.value || null;
109+
await startCamera(selected);
110+
});
111+
stopBtn.addEventListener('click', ()=>stopCamera());
112+
mirrorToggle.addEventListener('change', applyMirror);
113+
captureBtn.addEventListener('click', captureSnapshot);
114+
115+
// If the user changes camera from the dropdown, restart with that device
116+
deviceSelect.addEventListener('change', async ()=>{
117+
if(deviceSelect.value) await startCamera(deviceSelect.value);
118+
});
119+
120+
// On load: try to enumerate devices and set a helpful default
121+
(async function init(){
122+
if(!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia){
123+
setStatus('getUserMedia not supported in this browser');
124+
startBtn.disabled = true;
125+
return;
126+
}
127+
await enumerateCameras();
128+
// Try to pre-select a camera if available
129+
if(deviceSelect.options.length>0 && deviceSelect.options[0].value){
130+
deviceSelect.selectedIndex = 0;
131+
}
132+
setStatus('Ready — click "Start Camera"');
133+
})();
134+
135+
// Optional: stop camera when the page is hidden to be polite with permissions
136+
document.addEventListener('visibilitychange', ()=>{
137+
if(document.hidden) stopCamera();
138+
});
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
:root {
2+
--bg: #0f1724;
3+
--card: #0b1220;
4+
--accent: #06b6d4;
5+
color-scheme: dark
6+
}
7+
8+
9+
body {
10+
height: 100%;
11+
margin: 0;
12+
font-family: Inter, system-ui, Arial, Helvetica, sans-serif;
13+
background: linear-gradient(180deg, #071022 0%, #06111a 100%);
14+
color: #e6eef6
15+
}
16+
17+
.app {
18+
max-width: 980px;
19+
margin: 28px auto;
20+
padding: 18px;
21+
border-radius: 12px;
22+
background: linear-gradient(180deg, rgba(255, 255, 255, 0.02), transparent);
23+
box-shadow: 0 6px 30px rgba(2, 6, 23, 0.6)
24+
}
25+
26+
header {
27+
display: flex;
28+
align-items: center;
29+
gap: 14px
30+
}
31+
32+
h1 {
33+
font-size: 20px;
34+
margin: 0
35+
}
36+
37+
.controls {
38+
display: flex;
39+
flex-wrap: wrap;
40+
gap: 10px;
41+
margin-top: 12px
42+
}
43+
44+
button,
45+
select,
46+
label {
47+
background: transparent;
48+
border: 1px solid rgba(255, 255, 255, 0.08);
49+
padding: 8px 10px;
50+
border-radius: 8px;
51+
color: inherit
52+
}
53+
54+
button:hover,
55+
select:hover {
56+
border-color: var(--accent);
57+
cursor: pointer
58+
}
59+
60+
.stage {
61+
display: grid;
62+
grid-template-columns: 1fr 320px;
63+
gap: 14px;
64+
margin-top: 14px
65+
}
66+
67+
.viewer {
68+
background: #031022;
69+
border-radius: 10px;
70+
padding: 10px;
71+
display: flex;
72+
align-items: center;
73+
justify-content: center;
74+
min-height: 360px
75+
}
76+
77+
video {
78+
max-width: 100%;
79+
max-height: 100%;
80+
border-radius: 8px
81+
}
82+
83+
.sidebar {
84+
padding: 10px;
85+
border-radius: 8px;
86+
background: linear-gradient(180deg, #051426, #021018)
87+
}
88+
89+
.row {
90+
display: flex;
91+
align-items: center;
92+
gap: 8px;
93+
margin-bottom: 8px
94+
}
95+
96+
.label {
97+
font-size: 13px;
98+
color: rgba(255, 255, 255, 0.7)
99+
}
100+
101+
#canvas {
102+
max-width: 100%;
103+
border-radius: 8px;
104+
background: #081220
105+
}
106+
107+
.hint {
108+
font-size: 12px;
109+
color: rgba(255, 255, 255, 0.45)
110+
}
111+
112+
footer {
113+
margin-top: 12px;
114+
font-size: 12px;
115+
color: rgba(255, 255, 255, 0.45)
116+
}

0 commit comments

Comments
 (0)