├── docs
├── CNAME
├── index.html
└── assets
│ ├── index-DfwBGwiZ.css
│ └── index-Ss1YZ7nU.js
├── .vscode
└── settings.json
├── why-do-you-look-like-that.jpg
├── vite.config.js
├── readme.md
├── tsconfig.json
├── src
├── handlers.ts
├── errorMessages.ts
└── scripts.ts
├── package.json
├── index.html
├── style.css
└── .gitignore
/docs/CNAME:
--------------------------------------------------------------------------------
1 | hair.wesbos.com
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "typescript.tsdk": "node_modules/typescript/lib"
3 | }
--------------------------------------------------------------------------------
/why-do-you-look-like-that.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/wesbos/check-my-hair/HEAD/why-do-you-look-like-that.jpg
--------------------------------------------------------------------------------
/vite.config.js:
--------------------------------------------------------------------------------
1 | import { defineConfig } from 'vite';
2 |
3 | export default defineConfig({
4 | // Output to docs directory to match current build setup
5 | build: {
6 | outDir: 'docs',
7 | emptyOutDir: true,
8 | },
9 | // Development server configuration
10 | server: {
11 | port: 8888,
12 | open: true,
13 | }
14 | });
15 |
--------------------------------------------------------------------------------
/readme.md:
--------------------------------------------------------------------------------
1 | # Check Yourself before you wreck yourself
2 |
3 | A little app to check your webcam before you zoom away your afternoon. I built this because photo booth on the mac crops the webcam so you can't see everything.
4 |
5 | [hair.wesbos.com](https://hair.wesbos.com)
6 |
7 | 
8 |
9 | ## Todo:
10 |
11 | * [ ] Allow Webcam selection from the GUI
12 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "include": [
3 | "./src/**/*",
4 | "./index.html"
5 | ],
6 | "compilerOptions": {
7 | "strict": true,
8 | "target": "ESNext",
9 | "module": "ESNext",
10 | "moduleResolution": "bundler",
11 | "lib": [
12 | "ESNext",
13 | "DOM",
14 | "DOM.Iterable"
15 | ],
16 | "skipLibCheck": true,
17 | "allowImportingTsExtensions": true,
18 | "noEmit": true,
19 | "isolatedModules": true
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/src/handlers.ts:
--------------------------------------------------------------------------------
1 | export function setHandler(render: () => void) {
2 | return {
3 | get(target, prop, receiver) {
4 | const value = target[prop];
5 | if (!(value instanceof Function)) {
6 | return value;
7 | }
8 |
9 | return function (...args) {
10 | const result = value.apply(this === receiver ? target : this, args);
11 | if (prop === 'add' || prop === 'delete' || prop === 'clear') {
12 | console.log('Mutation, re-render');
13 | render();
14 | }
15 | return result;
16 | };
17 | },
18 | };
19 | }
20 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "check-my-hair",
3 | "version": "1.0.0",
4 | "description": "",
5 | "main": "index.js",
6 | "type": "module",
7 | "scripts": {
8 | "start": "vite",
9 | "dev": "vite",
10 | "build": "vite build",
11 | "preview": "vite preview"
12 | },
13 | "eslintConfig": {
14 | "extends": "eslint-config-wesbos/typescript"
15 | },
16 | "browserslist": [
17 | "Last 1 Chrome Versions"
18 | ],
19 | "author": "",
20 | "license": "ISC",
21 | "dependencies": {
22 | "vite": "^7.0.6"
23 | },
24 | "devDependencies": {
25 | "prettier": "^3.6.2",
26 | "typescript": "^5.9.2"
27 | },
28 | "packageManager": "pnpm@9.10.0+sha1.216899f511c8dfde183c7cb50b69009c779534a8"
29 | }
30 |
--------------------------------------------------------------------------------
/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | Check My Hair
7 |
8 |
9 |
10 |
11 |
12 |
13 | Made by Wes Bos.
14 | [source]
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
30 |
31 |
37 |
38 |
39 |
40 |
41 |
--------------------------------------------------------------------------------
/docs/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | Check My Hair
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 | Made by Wes Bos.
15 | [source]
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
31 |
32 |
38 |
39 |
40 |
41 |
--------------------------------------------------------------------------------
/docs/assets/index-DfwBGwiZ.css:
--------------------------------------------------------------------------------
1 | :root{--yellow: #ffc600;--blue: rgba(25, 53, 73, 1);--pink: #ff0088}html{box-sizing:border-box;font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Oxygen,Ubuntu,Cantarell,Open Sans,Helvetica Neue,sans-serif}*,*:before,*:after{box-sizing:inherit}body{padding:0;margin:0;background:var(--blue)}.wrap{display:grid;justify-content:center;align-items:center;align-content:center;text-align:center;grid-template-columns:1fr;grid-template-rows:1fr auto;padding:3%}.wrap>*{grid-column:1 / -1;grid-row:1 / -1}.text{font-size:20px;color:#ffc600;font-size:40px}video{border-radius:20px;height:100%;max-width:100%;box-shadow:0 0 10px 2px #0003;border:.5px solid rgba(255,255,255,.2);height:auto;max-height:100vh}.video{display:grid;grid-template-columns:repeat(auto-fit,minmax(500px,1fr));grid-gap:20px;align-items:center}@media (max-width: 500px){.video{grid-template-columns:1fr}}.camera{color:#fff;font-size:10px;transition:transform .1s;position:relative}button{background:var(--pink);border:2px solid transparent;padding:10px;border-radius:10px;color:#fff;font-size:15px;text-transform:uppercase;cursor:pointer}button:focus{border-color:var(--yellow);outline:0}button:hover{transform:scale(1.1)}.cred{color:#fff;grid-row:2;font-size:10px;text-align:center}a{color:var(--yellow)}.cameras:not(:has(.list>li)){display:none}
2 |
--------------------------------------------------------------------------------
/src/errorMessages.ts:
--------------------------------------------------------------------------------
1 | interface UserMediaErrorMessage {
2 | name: string;
3 | message: string;
4 | }
5 |
6 | export const errorMessages: UserMediaErrorMessage[] = [
7 | {
8 | name: 'NotFoundError',
9 | message: `Looks like we can't access your webcam`,
10 | },
11 | {
12 | name: 'DevicesNotFoundError',
13 | message: `Looks like we can't access your webcam`,
14 | },
15 | {
16 | name: 'NotReadableError',
17 | message: `Looks like we can't access your webcam`,
18 | },
19 | {
20 | name: 'TrackStartError',
21 | message: `Looks like we can't access your webcam`,
22 | },
23 | {
24 | name: 'OverconstrainedError',
25 | message: `Looks like we can't access your webcam`,
26 | },
27 | {
28 | name: 'ConstraintNotSatisfiedError',
29 | message: `Looks like we can't access your webcam`,
30 | },
31 | {
32 | name: 'NotAllowedError',
33 | message: `Looks like we can't access your webcam`,
34 | },
35 | {
36 | name: 'PermissionDeniedError',
37 | message: `Looks like we can't access your webcam`,
38 | },
39 | ];
40 |
41 | export function getFriendlyErrorMessage(err: Error): string {
42 | const errorMessage = errorMessages.find((error) => error.name === err.name);
43 | if (errorMessage) return errorMessage.message;
44 | return 'Oops - something went wrong!';
45 | }
46 |
--------------------------------------------------------------------------------
/style.css:
--------------------------------------------------------------------------------
1 | :root {
2 | --yellow: #ffc600;
3 | --blue: rgba(25, 53, 73, 1.00);
4 | --pink: #ff0088;
5 | }
6 |
7 | html {
8 | box-sizing: border-box;
9 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
10 | }
11 |
12 | *, *:before, *:after {
13 | box-sizing: inherit;
14 | }
15 |
16 | body {
17 | padding: 0;
18 | margin: 0;
19 | background: var(--blue);
20 | }
21 |
22 | .wrap {
23 | display: grid;
24 | justify-content: center;
25 | align-items: center;
26 | align-content: center;
27 | text-align: center;
28 | grid-template-columns: 1fr;
29 | grid-template-rows: 1fr auto;
30 | /* height: 100vh; */
31 | padding: 3%;
32 | }
33 |
34 | .wrap > * {
35 | grid-column: 1 / -1;
36 | grid-row: 1 / -1;
37 | }
38 |
39 | .text {
40 | font-size: 20px;
41 | color: #ffc600;
42 | font-size: 40px;
43 | }
44 |
45 | video {
46 | border-radius: 20px;
47 | height: 100%;
48 | max-width: 100%;
49 | box-shadow: 0 0 10px 2px rgba(0,0,0,0.2);
50 | border: 0.5px solid rgba(255,255,255,0.2);
51 | /* pointer-events: none; */
52 | height: auto;
53 | max-height: 100vh;
54 | }
55 |
56 | .video {
57 | display: grid;
58 | grid-template-columns: repeat(auto-fit, minmax(500px, 1fr));
59 | grid-gap: 20px;
60 | align-items: center;
61 | }
62 |
63 | @media (max-width: 500px) {
64 | .video {
65 | grid-template-columns: 1fr;
66 | }
67 | }
68 |
69 | .camera {
70 | color: white;
71 | font-size: 10px;
72 | transition: transform 0.1s;
73 | position: relative;
74 | /* grid-column: 1 / -1;
75 | grid-row: 1 / -1;
76 | opacity: 0.2; */
77 | }
78 |
79 |
80 | button {
81 | background: var(--pink);
82 | border: 2px solid transparent;
83 | padding: 10px;
84 | border-radius: 10px;
85 | color: white;
86 | font-size: 15px;
87 | text-transform: uppercase;
88 | cursor: pointer;
89 | }
90 |
91 | button:focus {
92 | border-color: var(--yellow);
93 | outline: 0;
94 | }
95 | button:hover {
96 | transform: scale(1.1)
97 | }
98 |
99 | .cred {
100 | color: white;
101 | grid-row: 2;
102 | font-size: 10px;
103 | text-align: center;
104 | }
105 |
106 | a {
107 | color: var(--yellow);
108 | }
109 |
110 | .cameras:not(:has(.list>li)) {
111 | display: none;
112 | }
113 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | .DS_Store
3 | logs
4 | *.log
5 | npm-debug.log*
6 | yarn-debug.log*
7 | yarn-error.log*
8 | lerna-debug.log*
9 |
10 | # Diagnostic reports (https://nodejs.org/api/report.html)
11 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
12 |
13 | # Runtime data
14 | pids
15 | *.pid
16 | *.seed
17 | *.pid.lock
18 |
19 | # Directory for instrumented libs generated by jscoverage/JSCover
20 | lib-cov
21 |
22 | # Coverage directory used by tools like istanbul
23 | coverage
24 | *.lcov
25 |
26 | # nyc test coverage
27 | .nyc_output
28 |
29 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
30 | .grunt
31 |
32 | # Bower dependency directory (https://bower.io/)
33 | bower_components
34 |
35 | # node-waf configuration
36 | .lock-wscript
37 |
38 | # Compiled binary addons (https://nodejs.org/api/addons.html)
39 | build/Release
40 |
41 | # Dependency directories
42 | node_modules/
43 | jspm_packages/
44 |
45 | # Snowpack dependency directory (https://snowpack.dev/)
46 | web_modules/
47 |
48 | # TypeScript cache
49 | *.tsbuildinfo
50 |
51 | # Optional npm cache directory
52 | .npm
53 |
54 | # Optional eslint cache
55 | .eslintcache
56 |
57 | # Microbundle cache
58 | .rpt2_cache/
59 | .rts2_cache_cjs/
60 | .rts2_cache_es/
61 | .rts2_cache_umd/
62 |
63 | # Optional REPL history
64 | .node_repl_history
65 |
66 | # Output of 'npm pack'
67 | *.tgz
68 |
69 | # Yarn Integrity file
70 | .yarn-integrity
71 |
72 | # dotenv environment variables file
73 | .env
74 | .env.test
75 |
76 | # parcel-bundler cache (https://parceljs.org/)
77 | .cache
78 | .parcel-cache
79 |
80 | # Next.js build output
81 | .next
82 | out
83 |
84 | # Nuxt.js build / generate output
85 | .nuxt
86 | dist
87 |
88 | # Gatsby files
89 | .cache/
90 | # Comment in the public line in if your project uses Gatsby and not Next.js
91 | # https://nextjs.org/blog/next-9-1#public-directory-support
92 | # public
93 |
94 | # vuepress build output
95 | .vuepress/dist
96 |
97 | # Serverless directories
98 | .serverless/
99 |
100 | # FuseBox cache
101 | .fusebox/
102 |
103 | # DynamoDB Local files
104 | .dynamodb/
105 |
106 | # TernJS port file
107 | .tern-port
108 |
109 | # Stores VSCode versions used for testing VSCode extensions
110 | .vscode-test
111 |
112 | # yarn v2
113 | .yarn/cache
114 | .yarn/unplugged
115 | .yarn/build-state.yml
116 | .yarn/install-state.gz
117 | .pnp.*
118 |
--------------------------------------------------------------------------------
/docs/assets/index-Ss1YZ7nU.js:
--------------------------------------------------------------------------------
1 | (function(){const e=document.createElement("link").relList;if(e&&e.supports&&e.supports("modulepreload"))return;for(const r of document.querySelectorAll('link[rel="modulepreload"]'))i(r);new MutationObserver(r=>{for(const n of r)if(n.type==="childList")for(const s of n.addedNodes)s.tagName==="LINK"&&s.rel==="modulepreload"&&i(s)}).observe(document,{childList:!0,subtree:!0});function o(r){const n={};return r.integrity&&(n.integrity=r.integrity),r.referrerPolicy&&(n.referrerPolicy=r.referrerPolicy),r.crossOrigin==="use-credentials"?n.credentials="include":r.crossOrigin==="anonymous"?n.credentials="omit":n.credentials="same-origin",n}function i(r){if(r.ep)return;r.ep=!0;const n=o(r);fetch(r.href,n)}})();function f(t){return{get(e,o,i){const r=e[o];return r instanceof Function?function(...n){const s=r.apply(this===i?e:this,n);return(o==="add"||o==="delete"||o==="clear")&&(console.log("Mutation, re-render"),t()),s}:r}}}const c=document.querySelector(".video");document.querySelector(".text");const m=document.querySelector(".start-camera"),u=localStorage.getItem("ignoredCameras"),d=document.querySelector(".ignoredCameras ul"),a=new Proxy(new Set,f(()=>{g(),l()}));function g(){if(!d)return;const t=Array.from(a).map(e=>`${e} `).join("");d.innerHTML=t}if(u){const t=JSON.parse(u);Array.isArray(t)&&(console.log("Restoring ignored cameras",t),t.forEach(e=>a.add(e)))}function v(t){const{id:e}=t.target.dataset;e&&(a.add(e),localStorage.setItem("ignoredCameras",JSON.stringify([...a])),console.log("ignored",a))}async function p(){const t=await navigator.mediaDevices.enumerateDevices(),e=new Set;return t.filter(i=>i.kind==="videoinput").filter(i=>!a.has(i.deviceId)).filter(i=>!a.has(i.label)).filter(i=>e.has(i.deviceId)?!1:(e.add(i.deviceId),!0))}function y(t){const e=`
2 |
3 |
4 |
5 |
${t.label}
6 |
7 |
8 |
9 | `,o=document.createRange().createContextualFragment(e);return o.querySelector("button.ignore")?.addEventListener("click",v),o}async function l(){if(!c)return;c.innerHTML="",await navigator.mediaDevices.getUserMedia({audio:!1,video:!0}),console.log("initial stream");const t=await p(),e=t.map(n=>navigator.mediaDevices.getUserMedia({audio:!1,video:{deviceId:{exact:n.deviceId}}})),o=await Promise.all(e).catch(console.error);t.map(y).forEach(n=>c.append(n)),c.querySelectorAll("video").forEach((n,s)=>{n.srcObject=o[s]})}m?.addEventListener("click",l);c?.addEventListener("click",t=>{if(t.target instanceof HTMLVideoElement){const e=t.target;"requestPictureInPicture"in t.target&&e.requestPictureInPicture(),"webkitSupportsPresentationMode"in e&&e.webkitSetPresentationMode(e.webkitPresentationMode==="picture-in-picture"?"inline":"picture-in-picture")}});d?.addEventListener("click",t=>{if(!(t.target instanceof HTMLButtonElement))return;const{removeIgnore:e}=t.target.dataset;e&&a.delete(e)});l();
10 |
--------------------------------------------------------------------------------
/src/scripts.ts:
--------------------------------------------------------------------------------
1 | import { getFriendlyErrorMessage } from './errorMessages';
2 | import { setHandler } from './handlers';
3 |
4 | const videoHolder = document.querySelector('.video');
5 | const text = document.querySelector('.text');
6 | const startbutton = document.querySelector('.start-camera');
7 | const existingIgnore = localStorage.getItem('ignoredCameras');
8 | const ignoreList =
9 | document.querySelector('.ignoredCameras ul');
10 | const ignoredCameras = new Proxy>(
11 | new Set(),
12 | setHandler(() => {
13 | renderIgnoreList();
14 | requestIntialAccess();
15 | })
16 | );
17 |
18 | function renderIgnoreList() {
19 | if (!ignoreList) return;
20 | const html = Array.from(ignoredCameras)
21 | .map((id) => `${id} `)
22 | .join('');
23 | ignoreList.innerHTML = html;
24 | }
25 |
26 | if (existingIgnore) {
27 | const cameraIds = JSON.parse(existingIgnore) as string[];
28 | if (Array.isArray(cameraIds)) {
29 | console.log('Restoring ignored cameras', cameraIds);
30 | cameraIds.forEach((id: string) => ignoredCameras.add(id));
31 | }
32 | }
33 |
34 | function ignoreCamera(e: Event) {
35 | const { id } = (e.target as HTMLButtonElement).dataset;
36 | if (!id) return;
37 | ignoredCameras.add(id);
38 | // mirror to local storage
39 | localStorage.setItem('ignoredCameras', JSON.stringify([...ignoredCameras]));
40 | console.log('ignored', ignoredCameras);
41 | }
42 |
43 | function handleError(err: Error) {
44 | if (!text) throw new Error('shit');
45 | text.textContent = getFriendlyErrorMessage(err);
46 | }
47 |
48 | async function getCameras() {
49 | const devices = await navigator.mediaDevices.enumerateDevices();
50 | const seenDeviceIds = new Set();
51 | const cameras = devices
52 | .filter((device) => device.kind === 'videoinput')
53 | .filter((device) => !ignoredCameras.has(device.deviceId))
54 | .filter((device) => !ignoredCameras.has(device.label))
55 | .filter((device) => {
56 | if (seenDeviceIds.has(device.deviceId)) {
57 | return false;
58 | }
59 | seenDeviceIds.add(device.deviceId);
60 | return true;
61 | });
62 | return cameras;
63 | }
64 |
65 | function createVideoElementFromCamera(camera: MediaDeviceInfo) {
66 | const markup = /* html */ `
67 |
68 |
69 |
70 |
${camera.label}
71 |
74 |
75 |
76 | `;
77 | const fragment = document.createRange().createContextualFragment(markup);
78 | fragment
79 | .querySelector('button.ignore')
80 | ?.addEventListener('click', ignoreCamera);
81 | return fragment;
82 | }
83 |
84 | async function requestIntialAccess() {
85 | // Check elements exist
86 | if (!videoHolder) return;
87 | // clear out old cameras
88 | videoHolder.innerHTML = '';
89 | const stream = await navigator.mediaDevices.getUserMedia({
90 | audio: false,
91 | video: true,
92 | });
93 | console.log('initial stream');
94 | const cameras = await getCameras();
95 | // See how many streams we are allowed access to
96 | const streamPromises = cameras.map((camera) =>
97 | navigator.mediaDevices.getUserMedia({
98 | audio: false,
99 | video: { deviceId: { exact: camera.deviceId } },
100 | })
101 | );
102 | // wait for access to ALL the streams
103 | const streams = await Promise.all(streamPromises).catch(console.error);
104 |
105 | // Create video elements for each
106 | const videoFragment: DocumentFragment[] = cameras.map(
107 | createVideoElementFromCamera
108 | );
109 | // dump them into the DOM
110 | videoFragment.forEach((el) => videoHolder.append(el));
111 |
112 | // Assign the src media of each camera to the video elements
113 | const videoElements = videoHolder.querySelectorAll('video');
114 | videoElements.forEach((el, i) => {
115 | el.srcObject = streams[i];
116 | });
117 | }
118 |
119 | type Mode = 'picture-in-picture' | 'inline';
120 | interface SafariHTMLVideoElement extends HTMLVideoElement {
121 | webkitSupportsPresentationMode: boolean;
122 | webkitPresentationMode: Mode;
123 | webkitSetPresentationMode: (mode: Mode) => void;
124 | }
125 |
126 | startbutton?.addEventListener('click', requestIntialAccess);
127 | videoHolder?.addEventListener('click', (e: MouseEvent) => {
128 | if (e.target instanceof HTMLVideoElement) {
129 | const video = e.target;
130 | if ('requestPictureInPicture' in e.target) {
131 | video.requestPictureInPicture();
132 | }
133 | // Safari? tHis doesnt work
134 | if ('webkitSupportsPresentationMode' in video) {
135 | (video as SafariHTMLVideoElement).webkitSetPresentationMode(
136 | (video as SafariHTMLVideoElement).webkitPresentationMode ===
137 | 'picture-in-picture'
138 | ? 'inline'
139 | : 'picture-in-picture'
140 | );
141 | }
142 | }
143 | });
144 |
145 | ignoreList?.addEventListener('click', (e: MouseEvent) => {
146 | if (!(e.target instanceof HTMLButtonElement)) return;
147 | const { removeIgnore } = e.target.dataset;
148 | if (!removeIgnore) return;
149 | ignoredCameras.delete(removeIgnore);
150 | });
151 |
152 | requestIntialAccess();
153 |
--------------------------------------------------------------------------------