├── 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 | ![yikes](why-do-you-look-like-that.jpg) 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 |
27 |

Hide Me!

28 | 29 |
30 | 31 |
32 |

Cameras

33 | 36 |
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 |
28 |

Hide Me!

29 | 30 |
31 | 32 |
33 |

Cameras

34 | 37 |
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 | --------------------------------------------------------------------------------