├── .distilla ├── .gcloudignore ├── .gitignore ├── LICENSE ├── README.md ├── api_key ├── app.yaml ├── index.html ├── package.json ├── src ├── app.js ├── config.js ├── effects │ ├── request-camera.js │ ├── request-fullscreen.js │ ├── snap.js │ └── translate.js └── views │ ├── base-view.js │ ├── error-view.js │ ├── list-view.js │ ├── main-view.js │ └── target-view.js └── style └── main.styl /.distilla: -------------------------------------------------------------------------------- 1 | preview: true 2 | 3 | hashing: 4 | index.html: 5 | - app.js 6 | - app.css 7 | 8 | tasks: 9 | npm run build-js: 10 | - app.js 11 | - app.js 12 | 13 | npm run build-css: 14 | - app.css 15 | - app.css 16 | -------------------------------------------------------------------------------- /.gcloudignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | src/ 3 | style/ 4 | .distilla 5 | .gitignore 6 | .gitcloudignore 7 | api_key 8 | LICENSE 9 | package.json 10 | package-lock.json 11 | README.md 12 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | /app.js 3 | /app.css 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2016 Dan Motzenbecker 2 | 3 | Permission is hereby granted, free of charge, to any person 4 | obtaining a copy of this software and associated documentation 5 | files (the "Software"), to deal in the Software without 6 | restriction, including without limitation the rights to use, 7 | copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the 9 | Software is furnished to do so, subject to the following 10 | conditions: 11 | 12 | The above copyright notice and this permission notice shall be 13 | included in all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 16 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES 17 | OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 18 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 19 | HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 20 | WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 21 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 22 | OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # [Thing Translator](https://thing-translator.appspot.com/) 2 | 3 | ### [An AI Experiment](https://aiexperiments.withgoogle.com/) 4 | 5 | ✨ [Try the live demo here.](https://thing-translator.appspot.com/) ✨ 6 | 7 | ![](https://oxism.com/thing-translator/thing-translator.gif) 8 | 9 | ![](https://oxism.com/thing-translator/img/1.jpg) 10 | 11 | ## ![](https://oxism.com/thing-translator/img/2.jpg) 12 | 13 | Thing Translator is a web app that lets you point your phone (or laptop) at 14 | stuff to hear to say it in a different language. It was developed as part of 15 | Google's [AI Experiments](https://aiexperiments.withgoogle.com/) project. You 16 | can try the app [here](https://thing-translator.appspot.com/). 17 | 18 | Behind the scenes Thing Translator is using Google's 19 | [Cloud Vision](https://cloud.google.com/vision/) and 20 | [Translate](https://cloud.google.com/translate/) APIs. 21 | 22 | ### Development 23 | 24 | To start a development server on `9966` that will watch for code changes simply run: 25 | 26 | ``` 27 | $ npm start 28 | ``` 29 | 30 | You will need to set your API key in `src/config.js`. 31 | 32 | To optimize the output for production, run: 33 | 34 | ``` 35 | $ npm run build 36 | ``` 37 | 38 | For production builds, replace the contents of the `api_key` file with your 39 | production key. 40 | 41 | If you'd like to create a fork or a similar project, you'll need to setup some 42 | API keys on [Google Cloud Platform](https://cloud.google.com/). 43 | -------------------------------------------------------------------------------- /api_key: -------------------------------------------------------------------------------- 1 | ceci_n’est_pas_une_key 2 | -------------------------------------------------------------------------------- /app.yaml: -------------------------------------------------------------------------------- 1 | runtime: python39 2 | 3 | handlers: 4 | - url: / 5 | static_files: index.html 6 | upload: index.html 7 | secure: always 8 | 9 | - url: /app.js 10 | static_files: app.js 11 | upload: app.js 12 | 13 | - url: /app.css 14 | static_files: app.css 15 | upload: app.css 16 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 9 | 13 | 14 | 19 | 20 | Thing Translator 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "thing-translator", 3 | "version": "0.0.2", 4 | "description": "Thing Translator is an AI Experiment that lets you point your camera at things to hear how to say them in a different language.", 5 | "private": true, 6 | "main": "src/app.js", 7 | "author": { 8 | "name": "Dan Motzenbecker", 9 | "email": "dan@oxism.com", 10 | "url": "http://oxism.com" 11 | }, 12 | "scripts": { 13 | "build": "npm run build-css && npm run build-js", 14 | "build-js": "NODE_ENV=production browserify -e src/app.js -t babelify -g envify -g unassertify -g uglifyify | sed 's/@@@@@/'$(rev < api_key)'/g' > app.js", 15 | "build-css": "stylus -c -u nib style/main.styl -o app.css", 16 | "deploy": "npm run build && gcloud app deploy --project=thing-translator --quiet", 17 | "start": "parallelshell 'npm run watch' 'npm run watch-style'", 18 | "watch": "budo src/app.js --live --host localhost -- -t babelify", 19 | "watch-style": "stylus -c -u nib -w style/main.styl -o app.css" 20 | }, 21 | "license": "MIT", 22 | "dependencies": { 23 | "array.prototype.find": "^2.1.1", 24 | "array.prototype.findindex": "^2.1.0", 25 | "choo": "^4.0.3", 26 | "he": "^1.2.0", 27 | "nib": "^1.1.2", 28 | "xhr": "^2.6.0" 29 | }, 30 | "devDependencies": { 31 | "@babel/core": "^7.13.10", 32 | "@babel/preset-env": "^7.9.0", 33 | "babel-plugin-yo-yoify": "^2.0.0", 34 | "babelify": "^10.0.0", 35 | "browserify": "^17.0.0", 36 | "budo": "^11.6.3", 37 | "distilla": "0.0.1", 38 | "envify": "^4.1.0", 39 | "parallelshell": "3.0.1", 40 | "stylus": "^0.54.8", 41 | "uglifyify": "^5.0.2", 42 | "unassertify": "^2.1.1" 43 | }, 44 | "browserify": { 45 | "transform": [ 46 | [ 47 | "babelify", 48 | { 49 | "presets": [ 50 | "@babel/preset-env" 51 | ], 52 | "plugins": [ 53 | "yo-yoify" 54 | ] 55 | } 56 | ] 57 | ] 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/app.js: -------------------------------------------------------------------------------- 1 | require('array.prototype.find').shim() 2 | require('array.prototype.findindex').shim() 3 | 4 | import choo from 'choo' 5 | import requestCamera from './effects/request-camera' 6 | import requestFullscreen from './effects/request-fullscreen' 7 | import snap from './effects/snap' 8 | import translate from './effects/translate' 9 | import baseView from './views/base-view' 10 | import {langList} from './config' 11 | 12 | const app = choo() 13 | 14 | app.model({ 15 | state: { 16 | activeView: 'main', 17 | cameraReady: false, 18 | cameraError: false, 19 | stream: null, 20 | video: null, 21 | ctx: null, 22 | canvas: null, 23 | isSnapping: false, 24 | firstTime: true, 25 | fullscreen: false, 26 | label: '', 27 | translation: '', 28 | activeLang: langList[0], 29 | targetLang: 'english', 30 | guesses: '', 31 | rotateTerms: true 32 | }, 33 | subscriptions: [ 34 | (send, done) => 35 | window.document.addEventListener('DOMContentLoaded', () => 36 | send('requestCamera', done) 37 | ) 38 | ], 39 | reducers: { 40 | showList: () => ({activeView: 'list'}), 41 | showMain: () => ({activeView: 'main'}), 42 | cameraError: () => ({cameraError: true}), 43 | startSnap: () => ({isSnapping: true, firstTime: false}), 44 | endSnap: () => ({isSnapping: false}), 45 | setFullscreen: () => ({fullscreen: true}), 46 | setLabelPair: (_, labels) => labels, 47 | changeLang: (_, lang) => ({ 48 | activeView: 'main', 49 | activeLang: lang, 50 | label: '', 51 | translation: '' 52 | }), 53 | setStream: (_, {stream, video, ctx, canvas}) => ({ 54 | cameraReady: true, 55 | stream, 56 | video, 57 | ctx, 58 | canvas 59 | }) 60 | }, 61 | effects: { 62 | requestCamera, 63 | snap, 64 | translate, 65 | requestFullscreen 66 | } 67 | }) 68 | 69 | app.router({default: '/'}, [['/', baseView]]) 70 | window.document.body.appendChild(app.start()) 71 | -------------------------------------------------------------------------------- /src/config.js: -------------------------------------------------------------------------------- 1 | const googleKey = '@@@@@'.split('').reverse().join('') 2 | 3 | export const apiUrls = { 4 | cloudVision: 5 | 'https://vision.googleapis.com/v1/images:annotate?key=' + googleKey, 6 | translate: 'https://www.googleapis.com/language/translate/v2?key=' + googleKey 7 | } 8 | 9 | const queryLangs = window.location.search.slice(1) 10 | 11 | export const langList = queryLangs 12 | ? queryLangs.split(',') 13 | : [ 14 | 'spanish', 15 | 'french', 16 | 'german', 17 | 'italian', 18 | 'chinese', 19 | 'japanese', 20 | 'korean', 21 | 'hindi', 22 | 'dutch' 23 | ] 24 | -------------------------------------------------------------------------------- /src/effects/request-camera.js: -------------------------------------------------------------------------------- 1 | const {MediaStreamTrack} = window 2 | const {mediaDevices} = navigator 3 | const sourceEnumSupport = mediaDevices && mediaDevices.enumerateDevices 4 | const streamTrackSupport = MediaStreamTrack && MediaStreamTrack.getSources 5 | const sourceSupport = sourceEnumSupport || streamTrackSupport 6 | 7 | let attemptedTwice = false 8 | 9 | const getUserMedia = (() => { 10 | const fn = 11 | navigator.getUserMedia || 12 | navigator.webkitGetUserMedia || 13 | navigator.mozGetUserMedia 14 | return fn ? fn.bind(navigator) : null 15 | })() 16 | 17 | const findBestSource = sources => { 18 | let source = null 19 | 20 | if (sourceSupport && sources && sources.length) { 21 | if (sourceEnumSupport) { 22 | for (let i = 0; i < sources.length; i++) { 23 | const candidate = sources[i] 24 | 25 | if (candidate.kind === 'videoinput') { 26 | if (typeof candidate.getCapabilities === 'function') { 27 | const capabilities = candidate.getCapabilities() 28 | 29 | if (capabilities && capabilities.facingMode === 'environment') { 30 | source = candidate 31 | break 32 | } 33 | } 34 | 35 | if (/facing back/i.test(candidate.label)) { 36 | source = candidate 37 | break 38 | } 39 | } 40 | } 41 | } else { 42 | source = sources.find(s => s.facing === 'environment') 43 | if (!source) { 44 | source = sources.find(s => s.kind === 'video') 45 | } 46 | } 47 | } 48 | 49 | return source 50 | } 51 | 52 | const activateCamera = (send, done, noConstraint) => { 53 | navigator.mediaDevices 54 | .getUserMedia({ 55 | audio: false, 56 | video: noConstraint || {facingMode: 'environment'} 57 | }) 58 | .then(stream => cameraSuccess(stream, send, done)) 59 | .catch(err => { 60 | if (!noConstraint && err.name === 'ConstraintNotSatisfiedError') { 61 | return activateCamera(send, done, true) 62 | } 63 | console.error(err) 64 | send('cameraError', done) 65 | }) 66 | } 67 | 68 | const activateCameraLegacy = (sources, send, done) => { 69 | const source = findBestSource(sources) 70 | 71 | getUserMedia( 72 | { 73 | audio: false, 74 | video: source 75 | ? { 76 | optional: [ 77 | {sourceId: sourceEnumSupport ? source.deviceId : source.id} 78 | ] 79 | } 80 | : true 81 | }, 82 | stream => { 83 | if (sourceEnumSupport && !source && !attemptedTwice) { 84 | attemptedTwice = true 85 | setTimeout(() => { 86 | stream.getTracks().forEach(track => track.stop()) 87 | enumerateDevices(send, done) 88 | }, 1) 89 | return 90 | } 91 | cameraSuccess(stream, send, done) 92 | }, 93 | err => { 94 | console.error(err) 95 | send('cameraError', done) 96 | } 97 | ) 98 | } 99 | 100 | const cameraSuccess = (stream, send, done) => { 101 | const canvas = window.document.getElementById('canvas') 102 | const videoEl = window.document.getElementById('video') 103 | 104 | videoEl.srcObject = stream 105 | 106 | send( 107 | 'setStream', 108 | { 109 | video: videoEl, 110 | ctx: canvas.getContext('2d'), 111 | stream, 112 | canvas 113 | }, 114 | done 115 | ) 116 | } 117 | 118 | const enumerateDevices = (send, done) => 119 | mediaDevices 120 | .enumerateDevices() 121 | .then(sources => activateCameraLegacy(sources, send, done)) 122 | .catch(() => activateCameraLegacy(null, send, done)) 123 | 124 | export default function requestCamera(state, _, send, done) { 125 | if (state.cameraReady) { 126 | return 127 | } 128 | 129 | if (navigator.mediaDevices && navigator.mediaDevices.getUserMedia) { 130 | return activateCamera(send, done) 131 | } 132 | 133 | if (!getUserMedia) { 134 | return send('cameraError', done) 135 | } 136 | 137 | if (sourceEnumSupport) { 138 | enumerateDevices(send, done) 139 | } else if (streamTrackSupport) { 140 | MediaStreamTrack.getSources(sources => 141 | activateCameraLegacy(sources, send, done) 142 | ) 143 | } else { 144 | activateCameraLegacy(null, send, done) 145 | } 146 | } 147 | -------------------------------------------------------------------------------- /src/effects/request-fullscreen.js: -------------------------------------------------------------------------------- 1 | const {body} = window.document 2 | 3 | export default function requestFullscreen(state, _, send, done) { 4 | if (body.requestFullscreen) { 5 | body.requestFullscreen() 6 | } else if (body.webkitRequestFullscreen) { 7 | body.webkitRequestFullscreen() 8 | } else if (body.mozRequestFullScreen) { 9 | body.mozRequestFullScreen() 10 | } else if (body.msRequestFullscreen) { 11 | body.msRequestFullscreen() 12 | } 13 | send('setFullscreen', done) 14 | } 15 | -------------------------------------------------------------------------------- /src/effects/snap.js: -------------------------------------------------------------------------------- 1 | import xhr from 'xhr' 2 | import {apiUrls} from '../config' 3 | 4 | const breakPoint = 800 5 | const canvSize = 640 6 | const targetPct = 0.7 7 | const targetTop = 0.4 8 | 9 | export default function snap(state, _, send, done) { 10 | send('startSnap', done) 11 | 12 | const winW = window.innerWidth 13 | const winH = window.innerHeight 14 | const vidW = state.video.videoWidth 15 | const vidH = state.video.videoHeight 16 | 17 | if (winW >= breakPoint) { 18 | const cropSize = Math.min(winW, winH) * targetPct 19 | const sourceSize = (cropSize / Math.max(winW, winH)) * vidW 20 | 21 | state.canvas.width = state.canvas.height = canvSize 22 | 23 | state.ctx.drawImage( 24 | state.video, 25 | Math.round(((winW / 2 - cropSize / 2) / winW) * vidW), 26 | Math.round(((winH * targetTop - cropSize / 2) / winH) * vidH), 27 | sourceSize, 28 | sourceSize, 29 | 0, 30 | 0, 31 | canvSize, 32 | canvSize 33 | ) 34 | } else { 35 | state.canvas.width = vidW 36 | state.canvas.height = vidH 37 | state.ctx.drawImage(state.video, 0, 0) 38 | } 39 | 40 | xhr.post( 41 | apiUrls.cloudVision, 42 | { 43 | json: { 44 | requests: [ 45 | { 46 | image: { 47 | content: state.canvas 48 | .toDataURL('image/jpeg', 1) 49 | .replace('data:image/jpeg;base64,', '') 50 | }, 51 | features: {type: 'LABEL_DETECTION', maxResults: 10} 52 | } 53 | ] 54 | } 55 | }, 56 | (err, res, body) => { 57 | let labels 58 | if ( 59 | err || 60 | !body.responses || 61 | !body.responses.length || 62 | !body.responses[0].labelAnnotations 63 | ) { 64 | labels = [] 65 | } else { 66 | labels = body.responses[0].labelAnnotations 67 | } 68 | send('translate', labels, done) 69 | setTimeout(send.bind(null, 'endSnap', done), 200) 70 | } 71 | ) 72 | } 73 | -------------------------------------------------------------------------------- /src/effects/translate.js: -------------------------------------------------------------------------------- 1 | import xhr from 'xhr' 2 | import he from 'he' 3 | import {apiUrls} from '../config' 4 | 5 | const {speechSynthesis, SpeechSynthesisUtterance} = window 6 | 7 | const speechSupport = speechSynthesis && SpeechSynthesisUtterance 8 | const filterLong = true 9 | const lengthLimit = 8 10 | 11 | const speak = (text, lang, cb) => { 12 | if (!speechSupport) { 13 | cb && cb() 14 | return 15 | } 16 | 17 | const msg = new SpeechSynthesisUtterance() 18 | msg.text = text 19 | msg.lang = voices[voiceMap[lang]].lang 20 | msg.voiceURI = voices[voiceMap[lang]].voiceURI 21 | cb && msg.addEventListener('end', cb) 22 | 23 | if (text) { 24 | speechSynthesis.speak(msg) 25 | } else { 26 | cb && cb() 27 | } 28 | } 29 | 30 | let voices = speechSupport ? speechSynthesis.getVoices() : [] 31 | let voiceMap = null 32 | 33 | const setVoiceMap = voiceList => { 34 | voices = voiceList 35 | 36 | const voiceRxs = { 37 | english: /en(-|_)gb/i, 38 | spanish: /es(-|_)(mx|es)/i, 39 | german: /de(-|_)de/i, 40 | french: /fr(-|_)fr/i, 41 | chinese: /zh(-|_)cn/i, 42 | italian: /it(-|_)it/i, 43 | korean: /ko(-|_)kr/i, 44 | japanese: /ja(-|_)jp/i, 45 | dutch: /nl(-|_)nl/i, 46 | hindi: /hi(-|_)in/i 47 | } 48 | 49 | voiceMap = Object.keys(voiceRxs).reduce((a, k) => { 50 | a[k] = voices.findIndex(v => voiceRxs[k].test(v.lang)) 51 | return a 52 | }, {}) 53 | } 54 | 55 | if (voices.length) { 56 | setVoiceMap(voices) 57 | } else if (speechSupport) { 58 | speechSynthesis.onvoiceschanged = () => 59 | setVoiceMap(speechSynthesis.getVoices()) 60 | } 61 | 62 | const langMap = { 63 | english: 'en', 64 | spanish: 'es', 65 | german: 'de', 66 | french: 'fr', 67 | chinese: 'zh', 68 | italian: 'it', 69 | korean: 'ko', 70 | japanese: 'ja', 71 | dutch: 'nl', 72 | hindi: 'hi' 73 | } 74 | 75 | const cache = {} 76 | 77 | export default function translate(state, raw, send, done) { 78 | const failureState = () => 79 | send('setLabelPair', {label: '?', translation: '?', guesses: ''}, done) 80 | 81 | if (!raw.length) { 82 | return failureState() 83 | } 84 | 85 | const labels = raw.map(l => l.description) 86 | let filtered = filterLong 87 | ? labels.filter(t => t.length <= lengthLimit) 88 | : labels 89 | 90 | if (!filtered.length) { 91 | filtered = labels 92 | } 93 | 94 | const guesses = raw 95 | .slice(0, 3) 96 | .map(o => `${o.description}: ${Math.round(o.score * 100)}%`) 97 | .join(', ') 98 | 99 | let term = filtered[0] 100 | 101 | if (state.rotateTerms && term === state.label && filtered.length > 1) { 102 | term = filtered.slice(1)[Math.floor(Math.random() * (filtered.length - 1))] 103 | } 104 | 105 | if (!cache[state.activeLang]) { 106 | cache[state.activeLang] = {} 107 | } 108 | 109 | const cacheHit = cache[state.activeLang][term] 110 | if (cacheHit) { 111 | send( 112 | 'setLabelPair', 113 | {label: he.decode(term), translation: cacheHit, guesses}, 114 | done 115 | ) 116 | speak(cacheHit, state.activeLang, speak.bind(null, term, state.targetLang)) 117 | return 118 | } 119 | 120 | xhr.get( 121 | `${apiUrls.translate}&q=${term}&source=en&target=${ 122 | langMap[state.activeLang] 123 | }`, 124 | (err, res, body) => { 125 | if (err) { 126 | return failureState() 127 | } 128 | 129 | const translation = he.decode( 130 | JSON.parse(body).data.translations[0].translatedText 131 | ) 132 | send('setLabelPair', {label: he.decode(term), translation, guesses}, done) 133 | speak( 134 | translation, 135 | state.activeLang, 136 | speak.bind(null, term, state.targetLang) 137 | ) 138 | cache[state.activeLang][term] = translation 139 | } 140 | ) 141 | } 142 | -------------------------------------------------------------------------------- /src/views/base-view.js: -------------------------------------------------------------------------------- 1 | import html from 'choo/html' 2 | import errorView from './error-view' 3 | import targetView from './target-view' 4 | import mainView from './main-view' 5 | import listView from './list-view' 6 | 7 | export default (state, prev, send) => 8 | html` 9 |
15 |
16 | 17 | 24 | 25 | 26 | 27 | ${state.cameraError 28 | ? errorView() 29 | : html` 30 |
31 | ${targetView(state, prev, send)} 32 | ${state.activeView === 'main' 33 | ? mainView(state, prev, send) 34 | : listView(state, prev, send)} 35 |
36 | `} 37 |
38 | ` 39 | -------------------------------------------------------------------------------- /src/views/error-view.js: -------------------------------------------------------------------------------- 1 | import html from 'choo/html' 2 | 3 | export default () => 4 | html` 5 |

6 | ${/iPhone|iPad|iPod/i.test(navigator.userAgent) && !window.MSStream 7 | ? 'On iOS, Safari is the only browser allowed to use the camera. Please try using Safari.' 8 | : 'Your browser or device doesn’t allow access to the camera. Please try using a modern browser.'} 9 |

10 | ` 11 | -------------------------------------------------------------------------------- /src/views/list-view.js: -------------------------------------------------------------------------------- 1 | import html from 'choo/html' 2 | import {langList} from '../config' 3 | 4 | export default (state, prev, send) => 5 | html` 6 |
7 |
×
8 | 21 |
22 | ` 23 | -------------------------------------------------------------------------------- /src/views/main-view.js: -------------------------------------------------------------------------------- 1 | import html from 'choo/html' 2 | 3 | export default (state, prev, send) => 4 | html` 5 |
6 | ${!state.firstTime && state.translation 7 | ? html` 8 |
9 |

${state.translation}

10 |

${state.activeLang}

11 |
12 | ` 13 | : null} 14 | ${!state.firstTime && state.label 15 | ? html` 16 |
17 |

${state.label}

18 |

${state.targetLang}

19 |
20 | ` 21 | : null} 22 | ${state.cameraReady 23 | ? html` 24 |
29 | ` 30 | : null} 31 | ${state.firstTime && state.cameraReady 32 | ? html`
Try taking a picture of something.
` 33 | : null} 34 |
${state.guesses}
35 |
36 | ` 37 | -------------------------------------------------------------------------------- /src/views/target-view.js: -------------------------------------------------------------------------------- 1 | import html from 'choo/html' 2 | 3 | export default state => 4 | state.activeView === 'list' || state.firstTime 5 | ? null 6 | : html` 7 |
8 | ` 9 | -------------------------------------------------------------------------------- /style/main.styl: -------------------------------------------------------------------------------- 1 | @import 'nib' 2 | 3 | $yellow = #ffc234 4 | 5 | * 6 | box-sizing border-box 7 | margin 0 8 | padding 0 9 | 10 | html 11 | font-family 'Montserrat', 'Helvetica Neue', Helvetica, sans-serif 12 | 13 | body 14 | background-color #000 15 | color #fff 16 | margin 0 17 | height 100vh 18 | overflow hidden 19 | 20 | 21 | @keyframes flash 22 | 0% 23 | background-color rgba(255, 255, 255, 1) 24 | 25 | 100% 26 | background-color rgba(255, 255, 255, 0) 27 | 28 | #target 29 | position absolute 30 | pointer-events none 31 | top 0 32 | bottom 0 33 | left 0 34 | right 0 35 | 36 | &.flashing 37 | animation flash .3s ease-out 38 | 39 | 40 | .button 41 | text-transform uppercase 42 | font-size 7vw 43 | border 0.5vw solid #fff 44 | padding 3vw 45 | cursor pointer 46 | 47 | 48 | #intro 49 | text-align center 50 | padding 4vw 51 | h1 52 | font-size 15vw 53 | font-weight normal 54 | 55 | h2 56 | font-weight normal 57 | font-size 5vw 58 | line-height 1.7 59 | padding 4vw 60 | 61 | #video 62 | position fixed 63 | top 50% 64 | left 50% 65 | min-width 100% 66 | min-height 100% 67 | width auto 68 | height auto 69 | z-index -100 70 | transform translateX(-50%) translateY(-50%) 71 | pointer-events none 72 | 73 | #lang-list 74 | ul 75 | position absolute 76 | top 50% 77 | left 0 78 | right 0 79 | list-style none 80 | text-align center 81 | transform translateY(-50%) 82 | 83 | li 84 | font-size 4.4vh 85 | text-transform uppercase 86 | line-height 2.2 87 | cursor pointer 88 | 89 | &.active 90 | color $yellow 91 | 92 | &:hover 93 | opacity .6 94 | 95 | .x 96 | position fixed 97 | z-index 2 98 | font-size 20vmin 99 | right 3vmin 100 | top -2.4vmin 101 | cursor pointer 102 | 103 | &:hover 104 | opacity .6 105 | 106 | 107 | #main-view 108 | text-align center 109 | position absolute 110 | top 7vh 111 | bottom 45vmin 112 | left 0 113 | right 0 114 | width 100vw 115 | height 60vh 116 | transition opacity .2s ease-out 117 | 118 | &.faded 119 | pointer-events none 120 | opacity 0 121 | 122 | .row 123 | width 100% 124 | height 50% 125 | margin 0 auto 126 | 127 | h2, h4 128 | font-weight normal 129 | position relative 130 | top 50% 131 | transform translateY(-50%) 132 | width 100% 133 | 134 | h2 135 | font-size 12vmin 136 | white-space nowrap 137 | overflow hidden 138 | text-overflow ellipsis 139 | 140 | 141 | h4 142 | text-transform uppercase 143 | font-size 4vmin 144 | 145 | &:first-child 146 | h4 147 | text-decoration underline 148 | color $yellow 149 | &:hover 150 | cursor pointer 151 | opacity .6 152 | 153 | 154 | 155 | #main-view.spell-view 156 | div:first-child 157 | border-bottom none 158 | 159 | h2 160 | font-size 45vw 161 | top 40vh 162 | 163 | 164 | #canvas 165 | display none 166 | 167 | 168 | #shroud 169 | background-color rgba(0, 0, 0, .2) 170 | position fixed 171 | top 0 172 | bottom 0 173 | left 0 174 | right 0 175 | 176 | 177 | #shutter-button 178 | position fixed 179 | width 19vmin !important 180 | height 19vmin !important 181 | border-radius 50% 182 | background-color #fff 183 | border 1px solid #fff 184 | padding 1.5vmin 185 | background-clip content-box 186 | bottom 15vmin 187 | left 50% !important 188 | opacity .8 189 | transform translateX(-50%) 190 | cursor pointer 191 | transition opacity .2s 192 | -webkit-tap-highlight-color rgba(0,0,0,0) 193 | -webkit-tap-highlight-color transparent 194 | outline 0 195 | 196 | &:active 197 | opacity 1 198 | 199 | &:hover 200 | opacity .6 201 | 202 | &.busy 203 | opacity .3 204 | cursor wait 205 | 206 | 207 | .debug 208 | user-select none 209 | bottom 2.5vmin 210 | position fixed 211 | width 90% 212 | left 50% 213 | transform translateX(-50%) 214 | font-family monospace 215 | font-size 3vmin 216 | line-height 1.5 217 | 218 | 219 | @media (min-width 800px) 220 | #target 221 | left 50% 222 | top 40% 223 | border 2px dashed rgba(255, 255, 255, .5) 224 | transform translate3d(-50%, -50%, 0) 225 | display block 226 | height 70vmin 227 | width 70vmin 228 | 229 | #main-view 230 | .row 231 | 232 | h2 233 | font-size 60px 234 | h4 235 | font-size 20px 236 | bottom 70px 237 | 238 | #spinner 239 | top 40% !important 240 | 241 | 242 | #shutter-button 243 | width 100px !important 244 | height 100px !important 245 | padding 10px !important 246 | width 10vmin !important 247 | height 10vmin !important 248 | bottom 10vmin !important 249 | 250 | .x 251 | font-size 110px 252 | 253 | .debug 254 | font-size 1.6rem 255 | 256 | 257 | #first-time 258 | position fixed 259 | width 100% 260 | font-size 2vh 261 | font-weight normal 262 | position fixed 263 | top 45% 264 | width 100% 265 | font-size 4.5vmin 266 | font-weight normal 267 | transform translateY(-50%) 268 | 269 | 270 | @media (max-height 600px) 271 | #target 272 | display none 273 | 274 | 275 | #cam-error 276 | width 50% 277 | text-align center 278 | font-size 3.2vmin 279 | line-height 1.5 280 | position absolute 281 | top 50% 282 | left 50% 283 | transform translate3d(-50%, -50%, 0) 284 | 285 | 286 | #spinner 287 | animation rotator 1.4s linear infinite 288 | position fixed 289 | top 50% 290 | left 50% 291 | transform translate3d(-50%, -50%, 0) 292 | transition opacity .3s ease-out 293 | opacity 0 294 | 295 | &.active 296 | opacity 1 297 | 298 | circle 299 | fill none 300 | stroke-width 6 301 | stroke-linecap round 302 | cx 33 303 | cy 33 304 | r 30 305 | stroke-dasharray 187 306 | stroke-dashoffset 0 307 | stroke #fff 308 | transform-origin center 309 | animation dash 1.4s ease-in-out infinite 310 | 311 | 312 | @keyframes rotator 313 | 0% 314 | transform translate3d(-50%, -50%, 0) rotate(0deg) 315 | 316 | 100% 317 | transform translate3d(-50%, -50%, 0) rotate(270deg) 318 | 319 | 320 | @keyframes dash 321 | 0% 322 | stroke-dashoffset 187 323 | 324 | 50% 325 | stroke-dashoffset 46.75 326 | transform rotate(135deg) 327 | 328 | 100% 329 | stroke-dashoffset 187 330 | transform rotate(450deg) 331 | 332 | 333 | @media (max-height 450px) 334 | #main-view 335 | top 0 !important 336 | 337 | #shutter-button 338 | bottom 12vmin !important 339 | 340 | 341 | *::-webkit-media-controls-panel 342 | display none !important 343 | -webkit-appearance none 344 | 345 | *::--webkit-media-controls-play-button 346 | display none !important 347 | -webkit-appearance none 348 | 349 | *::-webkit-media-controls-start-playback-button 350 | display none !important 351 | -webkit-appearance none 352 | --------------------------------------------------------------------------------