├── README.md ├── style.css ├── manifest.json ├── .glitch-assets ├── sw.js ├── index.html └── script.js /README.md: -------------------------------------------------------------------------------- 1 | Road Trip 2 | ========= 3 | 4 | An app that reads to you about the places you visit. 5 | 6 | Live instance https://roadtrip.glitch.me/ -------------------------------------------------------------------------------- /style.css: -------------------------------------------------------------------------------- 1 | /* CSS files add styling rules to your content */ 2 | 3 | .page-content { 4 | padding: 16px; 5 | } 6 | 7 | #map { 8 | height: 300px 9 | } -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Road Trip", 3 | "short_name": "Road Trip", 4 | "start_url": "https://roadtrip.glitch.me/", 5 | "icons": [ 6 | { 7 | "src": "https://cdn.glitch.com/93aac3ad-5e37-4ee6-8bf0-133d676ba436%2Flogo.png?1530855990742", 8 | "sizes": "512x512", 9 | "type": "image/png" 10 | } 11 | ], 12 | "theme_color": "#3f51b5", 13 | "background_color": "#ffffff", 14 | "display": "standalone" 15 | } -------------------------------------------------------------------------------- /.glitch-assets: -------------------------------------------------------------------------------- 1 | {"name":"drag-in-files.svg","date":"2016-10-22T16:17:49.954Z","url":"https://cdn.hyperdev.com/drag-in-files.svg","type":"image/svg","size":7646,"imageWidth":276,"imageHeight":276,"thumbnail":"https://cdn.hyperdev.com/drag-in-files.svg","thumbnailWidth":276,"thumbnailHeight":276,"dominantColor":"rgb(102, 153, 205)","uuid":"adSBq97hhhpFNUna"} 2 | {"name":"click-me.svg","date":"2016-10-23T16:17:49.954Z","url":"https://cdn.hyperdev.com/click-me.svg","type":"image/svg","size":7116,"imageWidth":276,"imageHeight":276,"thumbnail":"https://cdn.hyperdev.com/click-me.svg","thumbnailWidth":276,"thumbnailHeight":276,"dominantColor":"rgb(243, 185, 186)","uuid":"adSBq97hhhpFNUnb"} 3 | {"name":"paste-me.svg","date":"2016-10-24T16:17:49.954Z","url":"https://cdn.hyperdev.com/paste-me.svg","type":"image/svg","size":7242,"imageWidth":276,"imageHeight":276,"thumbnail":"https://cdn.hyperdev.com/paste-me.svg","thumbnailWidth":276,"thumbnailHeight":276,"dominantColor":"rgb(42, 179, 185)","uuid":"adSBq97hhhpFNUnc"} 4 | {"uuid":"adSBq97hhhpFNUna","deleted":true} 5 | {"uuid":"adSBq97hhhpFNUnb","deleted":true} 6 | {"uuid":"adSBq97hhhpFNUnc","deleted":true} 7 | {"name":"logo.png","date":"2018-07-06T05:46:30.742Z","url":"https://cdn.glitch.com/93aac3ad-5e37-4ee6-8bf0-133d676ba436%2Flogo.png","type":"image/png","size":442308,"imageWidth":512,"imageHeight":512,"thumbnail":"https://cdn.glitch.com/93aac3ad-5e37-4ee6-8bf0-133d676ba436%2Fthumbnails%2Flogo.png","thumbnailWidth":330,"thumbnailHeight":330,"dominantColor":"rgb(206,173,154)","uuid":"JGjGnsHF4PAgHyKO"} 8 | {"name":"Wikipedia-logo.svg.png","date":"2018-07-14T21:15:43.752Z","url":"https://cdn.glitch.com/e2f643e1-ad10-4089-93d8-fc9e55b77d3b%2FWikipedia-logo.svg.png","type":"image/png","size":85149,"imageWidth":600,"imageHeight":600,"thumbnail":"https://cdn.glitch.com/e2f643e1-ad10-4089-93d8-fc9e55b77d3b%2Fthumbnails%2FWikipedia-logo.svg.png","thumbnailWidth":330,"thumbnailHeight":330,"dominantColor":null,"uuid":"V5Q8vIkJNV2V8N15"} 9 | -------------------------------------------------------------------------------- /sw.js: -------------------------------------------------------------------------------- 1 | importScripts('https://storage.googleapis.com/workbox-cdn/releases/3.0.0-beta.0/workbox-sw.js'); 2 | 3 | // Uncomment for debugging 4 | // workbox.core.setLogLevel(workbox.core.LOG_LEVELS.debug); 5 | 6 | workbox.routing.registerRoute( 7 | /^https:\/\/((\w+)\.googleapis\.com|www\.googletagmanager\.com|code\.getmdl\.io)\/.+$/, 8 | workbox.strategies.staleWhileRevalidate({ 9 | cacheName: 'google-cache', 10 | plugins: [ 11 | // Allow opaque responses 12 | new workbox.cacheableResponse.Plugin({ 13 | statuses: [0, 200] // YOLO 14 | }), 15 | ], 16 | }) 17 | ); 18 | 19 | workbox.routing.registerRoute( 20 | // Cache immutable files forever 21 | /^https:\/\/roadtrip-api\./, 22 | // Use the cache if it's available 23 | workbox.strategies.cacheFirst({ 24 | cacheName: 'api-cache', 25 | plugins: [ 26 | new workbox.expiration.Plugin({ 27 | // Cache for a maximum of 30 days 28 | maxAgeSeconds: 30 * 24 * 60 * 60, 29 | }) 30 | ], 31 | }) 32 | ); 33 | 34 | workbox.routing.registerRoute( 35 | // Cache immutable files forever 36 | /^https:\/\/[^.]+\.wikipedia\.org\//, 37 | // Use the cache if it's available 38 | workbox.strategies.cacheFirst({ 39 | cacheName: 'wiki-cache', 40 | plugins: [ 41 | new workbox.expiration.Plugin({ 42 | // Cache for a maximum of 2 days 43 | maxAgeSeconds: 2 * 24 * 60 * 60, 44 | }) 45 | ], 46 | }) 47 | ); 48 | 49 | workbox.routing.registerRoute( 50 | // Prefer the network but if it doesn't respond within 1 seconds, 51 | // fallback to a doc if we have a cached version that is max 52 | // 10 days old. 53 | // Basically that means that the schedule should load offline for the 54 | // duration of the conference 55 | /(\/|\.html|\.js|\.css)$/, 56 | // Use the network unless things are slow 57 | workbox.strategies.networkFirst({ 58 | cacheName: 'doc-cache', 59 | networkTimeoutSeconds: 1, 60 | plugins: [ 61 | new workbox.expiration.Plugin({ 62 | // Cache for a maximum of 10 days 63 | maxAgeSeconds: 10 * 24 * 60 * 60, 64 | }) 65 | ], 66 | }) 67 | ); 68 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Road Trip 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 16 | 17 | 18 | 19 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 |
39 |
40 |
41 | 42 | Road Trip 43 | 44 |
45 | 46 | 50 |
51 |
52 |
53 | Road Trip 54 | 58 |
59 |
60 |
61 |

This app will read to you about the places and points of interest you drive by.

62 |
63 | 65 |
66 | 69 | 70 | 73 | 76 | 79 | 82 |
83 |
84 |
85 |
86 | 87 |
88 |
89 |
90 |
91 | 92 | 93 | -------------------------------------------------------------------------------- /script.js: -------------------------------------------------------------------------------- 1 | let position; 2 | let manualPosition = false; 3 | let map; 4 | let locationData; 5 | let cancelSpeaking; 6 | let skipThisParagraph; 7 | const state = { 8 | playing: false, 9 | loading: false, 10 | paused: false, 11 | }; 12 | const pollSeconds = 30; 13 | 14 | const seen = {}; 15 | 16 | const interestingTypes = [ 17 | 'point_of_interest', 18 | 'natural_feature', 19 | 'neighborhood', 20 | 'sublocality', 21 | 'locality', 22 | ]; 23 | 24 | const languageMap = { 25 | 'English (US)': {wikiTag: 'en', speechTag: 'en-US', welcomeMsg: 'Welcome to your road trip.'}, 26 | 'English (UK)': {wikiTag: 'en', speechTag: 'en-GB', welcomeMsg: 'Welcome to your road trip.'}, 27 | 'Français': {wikiTag: 'fr', speechTag: 'fr-FR', welcomeMsg: 'Bienvenue dans votre voyage.'}, 28 | 'Cebuano': {wikiTag: 'ceb', speechTag: 'ceb', welcomeMsg: 'Welcome sa imong pagbiyahe sa dalan.'}, 29 | 'Svenska': {wikiTag: 'sv', speechTag: 'sv-SE', welcomeMsg: 'Välkommen till din vägresa.'}, 30 | 'Deutsch': {wikiTag: 'de', speechTag: 'de-DE', welcomeMsg: 'Willkommen zu Ihrem Roadtrip.'}, 31 | 'Nederlands ': {wikiTag: 'nl', speechTag: 'nl-NL', welcomeMsg: 'Welkom bij je roadtrip.'}, 32 | 'русский': {wikiTag: 'ru', speechTag: 'ru-RU', welcomeMsg: 'Добро пожаловать в свою поездку.'}, 33 | 'Italiano': {wikiTag: 'it', speechTag: 'it-IT', welcomeMsg: 'Benvenuto nel tuo viaggio.'}, 34 | 'Español': {wikiTag: 'es', speechTag: 'es', welcomeMsg: 'Bienvenido a tu viaje.'}, 35 | 'Polski': {wikiTag: 'pl', speechTag: 'pl-PL', welcomeMsg: 'Witamy w podróży.'}, 36 | 'Tiếng Việt': {wikiTag: 'vi', speechTag: 'vi-VN', welcomeMsg: 'Chào mừng bạn đến với chuyến đi của bạn.'}, 37 | '日本語': {wikiTag: 'ja', speechTag: 'ja-JP', welcomeMsg: 'あなたのロードトリップへようこそ。'}, 38 | '中文': {wikiTag: 'ch', speechTag: 'zh-CN', welcomeMsg: '欢迎来到您的公路旅行。'}, 39 | 'Português (PT)': {wikiTag: 'pt', speechTag: 'pt-PT', welcomeMsg: 'Bem-vindo à sua viagem.'}, 40 | 'Português (BR)': {wikiTag: 'pt', speechTag: 'pt-BR', welcomeMsg: 'Bem-vindo à sua viagem.'}, 41 | 'українська': {wikiTag: 'uk', speechTag: 'uk-UA', welcomeMsg: 'Ласкаво просимо до дорожньої подорожі.'}, 42 | 'فارسی': {wikiTag: 'fa', speechTag: 'fa-IR', welcomeMsg: 'به سفر جاده ای شما خوش آمدید'}, 43 | 'српски': {wikiTag: 'sr', speechTag: 'sr-BA', welcomeMsg: 'Добродошли на путовање.'}, 44 | 'Català': {wikiTag: 'ca', speechTag: 'ca-ES', welcomeMsg: 'Benvingut al vostre viatge.'}, 45 | 'العربية': {wikiTag: 'ar', speechTag: 'ar', welcomeMsg: 'مرحبا بك في رحلتك'}, 46 | }; 47 | 48 | function wikiUrl(path, api, mobile) { 49 | let url = 'https://' + state.lang.wikiTag; 50 | if (mobile) url += '.m'; 51 | return url + '.wikipedia.org/' + (api ? 'w/api.php?' : 'wiki/') + path 52 | } 53 | 54 | function startWatchLocation() { 55 | return new Promise(resolve => { 56 | navigator.geolocation.watchPosition(pos => { 57 | if (!manualPosition) { 58 | console.info('Updated position', pos); 59 | position = pos; 60 | if (map) { 61 | map.setCenter({lat: pos.coords.latitude, lng: pos.coords.longitude}); 62 | } 63 | } 64 | resolve(); 65 | }, error => { 66 | console.error('WatchPosition error.', error.message, error); 67 | alert('Failed to find your current location.') 68 | }, { 69 | enableHighAccuracy: false, 70 | maximumAge: 15000, 71 | }); 72 | }); 73 | } 74 | 75 | async function geoCode(position) { 76 | const response = await fetchWithTimeout('https://roadtrip-api.glitch.me/geocode?latlng=' 77 | + position.coords.latitude + ',' + position.coords.longitude); 78 | if (response.ok) { 79 | const json = await response.json(); 80 | //console.info('Geo coding result: ' + JSON.stringify(json)); 81 | if (json.status == 'OK') { 82 | return json.results; 83 | } else { 84 | throw new Error('Reverse geocoding failed: ' + JSON.stringify(json)); 85 | } 86 | } else { 87 | const text = await response.text(); 88 | throw new Error('Reverse geocoding failed: ' + text); 89 | } 90 | } 91 | 92 | function processResult(results) { 93 | return results.filter(result => { 94 | let found = false ; 95 | result.types.forEach(type => { 96 | let index = interestingTypes.indexOf(type); 97 | if (index == -1) { 98 | return; 99 | } 100 | if (!result.interestingness || result.interestingness > index) { 101 | result.interestingness = index; 102 | console.info('Location candidate', result.formatted_address, result.types); 103 | found = true; 104 | } 105 | }); 106 | return found; 107 | }).sort((a, b) => a.interestingness - b.interestingness); 108 | } 109 | 110 | async function searchWikipedia(term) { 111 | const response = await fetchWithTimeout(wikiUrl('action=opensearch&format=json&origin=*&limit=1&search=' + encodeURIComponent(term), true)); 112 | if (!response.ok) { 113 | console.error('Wikipedia call failed', response) 114 | throw new Error('Wikipedia search is down'); 115 | } 116 | const json = await response.json(); 117 | console.info('Search response', term, json); 118 | const title = json[1][0] 119 | if (title) { 120 | return title; 121 | } 122 | const parts = term.split(/\,\s+/); 123 | parts.pop(); 124 | const newTerm = parts.join(', '); 125 | if (newTerm) { 126 | return searchWikipedia(newTerm); 127 | } 128 | return null; 129 | } 130 | 131 | async function getContent(title) { 132 | console.info('Getting content'); 133 | const response = await fetchWithTimeout(wikiUrl('redirects=true&format=json&origin=*&action=query&prop=extracts|coordinates&titles=' + encodeURIComponent(title), true)); 134 | if (!response.ok) { 135 | console.error('Wikipedia content call failed', response) 136 | throw new Error('Wikipedia content is down'); 137 | } 138 | const json = await response.json(); 139 | const page = Object.values(json.query.pages)[0]; 140 | console.info('Page', page) 141 | seen[page.title] = true; 142 | return { 143 | url: wikiUrl(encodeURIComponent(page.title), false), 144 | title: page.title, 145 | label: page.title, 146 | content: simpleHtmlToText(page.extract.trim()), 147 | lang: state.lang.speechTag, 148 | coordinates: page.coordinates[0] ? { 149 | lat: page.coordinates[0].lat, 150 | lng: page.coordinates[0].lon, 151 | } : null, 152 | }; 153 | } 154 | 155 | async function getArticleForLocation() { 156 | console.info('Geo coding'); 157 | const locationResults = processResult(await geoCode(position)); 158 | console.info('Location results retrieved', locationResults.length); 159 | let result; 160 | while (result = locationResults.find(r => !seen[r.formatted_address])) { 161 | seen[result.formatted_address] = true; 162 | console.info('Searching for', result.formatted_address); 163 | const title = await searchWikipedia(result.formatted_address); 164 | if (!title) { 165 | continue; 166 | } 167 | console.info('Title', title); 168 | return getContent(title); 169 | }; 170 | return null; 171 | } 172 | 173 | async function getNearbyArticle() { 174 | console.info('Finding nearby article'); 175 | const response = await fetchWithTimeout(wikiUrl('action=query&format=json&origin=*&generator=geosearch&ggsradius=10000&ggsnamespace=0&ggslimit=50&formatversion=2&ggscoord=' + encodeURIComponent(position.coords.latitude) + '%7C' + encodeURIComponent(position.coords.longitude), true, true)); 176 | if (!response.ok) { 177 | console.error('Wikipedia nearby failed', response) 178 | throw new Error('Wikipedia nearby is down'); 179 | } 180 | const json = await response.json(); 181 | console.info('Nearby response', json); 182 | const pages = json.query.pages; 183 | for (let page of pages) { 184 | const title = page.title; 185 | if (seen[title]) { 186 | continue; 187 | } 188 | seen[title] = true; 189 | console.info('Title', title); 190 | return getContent(title); 191 | } 192 | return null; 193 | } 194 | 195 | async function speak(text, language) { 196 | // Mobile Chrome doesn't like long texts, so we just do one sentence at a time. 197 | // Make a sentence end a paragraph end. \w\w to not match e.g. 198 | const paras = text.split(/\n/).filter(p => p.trim()); 199 | for (let p of paras) { 200 | p = p.replace(/(\w\w\.)/, '$1\n') 201 | const sentences = p.split(/\.\n/).filter(e => e.trim()); 202 | for (let sentence of sentences) { 203 | await speakSentence(sentence, language); 204 | if (cancelSpeaking) { 205 | cancelSpeaking = false; 206 | console.info('Cancel speaking'); 207 | return; 208 | } 209 | if (skipThisParagraph) { 210 | skipThisParagraph = false; 211 | console.info('Skipping paragraph'); 212 | break; // Goes to next step in paras loop 213 | } 214 | } 215 | } 216 | 217 | } 218 | 219 | function speakSentence(text, language) { 220 | var utterance = new SpeechSynthesisUtterance(); 221 | utterance.text = text + '.'; 222 | utterance.lang = language; 223 | console.info('Start speaking', utterance.text); 224 | let interval; 225 | return new Promise(resolve => { 226 | let spoke = false; 227 | interval = setInterval(() => { 228 | if (window.speechSynthesis.speaking) { 229 | spoke = true; 230 | } 231 | if (spoke && !window.speechSynthesis.speaking) { 232 | if (state.paused) { 233 | return; 234 | } 235 | console.info('Detected end of speaking without end event', utterance.text); 236 | resolve(); 237 | } 238 | }, 100); 239 | utterance.onend = () => { 240 | console.info('End speaking', utterance.text); 241 | resolve(); 242 | }; 243 | utterance.onerror = e => { 244 | console.error('Error speaking', e.error, e); 245 | resolve(); 246 | }; 247 | utterance.onpause = () => console.info('Pause'); 248 | utterance.onresume = () => console.info('Resume'); 249 | window.speechSynthesis.speak(utterance); 250 | }).then(() => clearInterval(interval)) 251 | } 252 | 253 | async function talkAboutLocation(article) { 254 | state.status = `Reading about ${html(article.title)}`; 255 | render(); 256 | gtag('config', 'UA-121987888-1', { 257 | 'page_title' : 'Article ' + article.title, 258 | 'page_path': '/article/' + encodeURIComponent(article.title), 259 | }); 260 | if (article.coordinates && map) { 261 | article.marker = new google.maps.Marker({ 262 | position: article.coordinates, 263 | map: map, 264 | title: article.title, 265 | //label: article.title, 266 | }); 267 | } 268 | return speak(article.content, article.lang); 269 | } 270 | 271 | async function start() { 272 | state.playing = true; 273 | state.loading = true; 274 | state.lang = languageMap[$('language_inner_select').value]; 275 | state.status = 'Finding your location.' 276 | render(); 277 | const s = startWatchLocation(); 278 | $('toast').MaterialSnackbar.showSnackbar({ 279 | message: 'Please make sure your volume is turned up!', 280 | timeout: 10000, 281 | }); 282 | await speak(state.lang.welcomeMsg + '\n\n', state.lang.speechTag); 283 | await s; 284 | next(); 285 | } 286 | 287 | async function next() { 288 | console.info('Starting session'); 289 | state.playing = true; 290 | state.loading = true; 291 | state.status = 'Finding something interesting to read. I\'ll keep checking as you move.' 292 | render(); 293 | gtag('config', 'UA-121987888-1', { 294 | 'page_title' : 'Next', 295 | 'page_path': '/next/' + state.lang.speechTag, 296 | }); 297 | try { 298 | console.info('Finding article.'); 299 | let article = await getArticleForLocation(); 300 | if (!article) { 301 | console.info('Did not find location article. Trying nearby.'); 302 | article = await getNearbyArticle(); 303 | } 304 | if (!article) { 305 | console.info('Did not find article'); 306 | setTimeout(next, pollSeconds * 1000); 307 | return; 308 | } 309 | console.info('Retrieved article'); 310 | state.loading = false; 311 | render(); 312 | await talkAboutLocation(article); 313 | } catch (e) { 314 | console.error('Error :' + e, e); 315 | setTimeout(next, pollSeconds * 1000); 316 | return; 317 | } 318 | next(); 319 | } 320 | 321 | function pause() { 322 | state.paused = true; 323 | render() 324 | window.speechSynthesis.pause(); 325 | } 326 | 327 | function play() { 328 | state.paused = false; 329 | render() 330 | window.speechSynthesis.resume(); 331 | } 332 | 333 | function forward() { 334 | cancelSpeaking = true; 335 | window.speechSynthesis.cancel(); 336 | } 337 | 338 | function skipParagraph() { 339 | skipThisParagraph = true; 340 | window.speechSynthesis.cancel(); 341 | } 342 | 343 | function render() { 344 | $('html_start').style.display = display(!state.playing); 345 | $('language_select').style.display = display(!state.playing); 346 | $('html_pause').style.display = display(!state.loading && state.playing && !state.paused); 347 | $('html_play').style.display = display(!state.loading && state.playing && state.paused); 348 | $('html_next').style.display = display(!state.loading && state.playing); 349 | $('html_skip').style.display = display(!state.loading && state.playing); 350 | $('html_spinner').style.display = display(state.loading); 351 | $('html_title').innerHTML = state.status; 352 | } 353 | 354 | function simpleHtmlToText(html) { 355 | const div = document.createElement('div'); 356 | div.innerHTML = html; 357 | remove(div.querySelector('#References')); 358 | remove(div.querySelector('#See_also')); 359 | let text = div.textContent; 360 | text = text.replace(/(.)\n/g, '$1.\n'); 361 | // Remove stuff in parantheses. Nobody wants to hear that stuff. 362 | // This isn't how you pass the Google interview. 363 | // But it is technically O(n) 364 | for (let i = 0; i < 10; i++) { 365 | text = text.replace(/\([^\)]+\)/g, ''); 366 | } 367 | return text; 368 | } 369 | 370 | function remove(element) { 371 | if (!element) { 372 | return false; 373 | } 374 | return element.parentElement.removeChild(element); 375 | } 376 | 377 | function $(id) { 378 | return document.getElementById(id); 379 | } 380 | 381 | function display(bool) { 382 | return bool ? 'block' : 'none'; 383 | } 384 | 385 | function html(text) { 386 | const div = document.createElement('div'); 387 | div.textContent = text; 388 | return div.innerHTML; 389 | } 390 | 391 | function initMap() { 392 | const startLoc = {lat: -34.397, lng: 150.644}; 393 | map = new google.maps.Map($('map'), { 394 | center: startLoc, 395 | zoom: 11 396 | }); 397 | const centerMarker = new google.maps.Marker({ 398 | position: startLoc, 399 | map: map, 400 | title: 'Current Location' 401 | }); 402 | map.addListener('center_changed', e => { 403 | position = { 404 | coords: { 405 | longitude: map.getCenter().lng(), 406 | latitude: map.getCenter().lat(), 407 | } 408 | }; 409 | centerMarker.setPosition({ 410 | lat: map.getCenter().lat(), 411 | lng: map.getCenter().lng() 412 | }); 413 | console.info('Map position', position); 414 | manualPosition = true; 415 | }); 416 | } 417 | 418 | function initLanguageSelect(selected) { 419 | var fragment = document.createDocumentFragment(); 420 | 421 | for (let language of Object.keys(languageMap)) { 422 | var opt = document.createElement('option'); 423 | opt.innerHTML = language; 424 | opt.value = language; 425 | fragment.appendChild(opt); 426 | } 427 | 428 | $('language_inner_select').appendChild(fragment); 429 | $('language_inner_select').value = selected; 430 | } 431 | 432 | function selectLang(langTag) { 433 | let selected = null; 434 | for (var langValue in languageMap) { 435 | if (languageMap[langValue].speechTag === langTag || languageMap[langValue].wikiTag === langTag) { 436 | selected = langValue; 437 | break; 438 | } 439 | } 440 | 441 | if (!selected) { 442 | selected = 'Browser Language (' + langTag + ')'; 443 | languageMap[selected] = { 444 | speechTag: langTag, 445 | wikiTag: langTag.split('-')[0], 446 | welcomeMsg: '' 447 | }; 448 | } 449 | initLanguageSelect(selected); 450 | } 451 | 452 | function timeout(time, message) { 453 | return new Promise((resolve, reject) => { 454 | setTimeout(() => reject(new Error('Timeout: ' + message)), time); 455 | }) 456 | } 457 | 458 | function fetchWithTimeout(url, paras) { 459 | return Promise.race([fetch(url, paras), 460 | timeout(15 * 1000, 'Fetch timed out for ' + url)]); 461 | } 462 | 463 | async function guessLang() { 464 | const langTag = navigator.language; 465 | const wikiTag = langTag.split('-')[0]; 466 | const response = await fetchWithTimeout('https://' + wikiTag + '.wikipedia.org/w/api.php?redirects=true&format=json&origin=*&action=query&prop=extracts&titles=Main_Page'); 467 | if (response.ok) { 468 | console.info('Set language to browser language', langTag); 469 | return langTag; 470 | } 471 | } 472 | 473 | function init() { 474 | onunload = function() { 475 | window.speechSynthesis.cancel(); 476 | } 477 | navigator.serviceWorker.register("/sw.js"); 478 | 479 | const l = new URLSearchParams(location.search).get('lang'); 480 | if (l) { 481 | selectLang(encodeURIComponent(l)); 482 | } 483 | else { 484 | guessLang().then(selectLang); 485 | } 486 | } 487 | 488 | init(); --------------------------------------------------------------------------------