├── .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 | 
8 |
9 | 
10 |
11 | ## 
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 |
9 | ${langList.map(
10 | lang =>
11 | html`
12 |
16 | ${lang}
17 |
18 | `
19 | )}
20 |
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 |
--------------------------------------------------------------------------------