// ==UserScript==
// @name Internet Roadtrip GPS TTS (Integrated with Minimap + Puter.js)
// @namespace linktr.ee/gamerfronts
// @version 1.7
// @description GPS TTS with street names and turn-by-turn directions integrated with the Minimap, using Puter.js for voice synthesis. Overlay sits above the minimap for real-time navigation feedback.
// @author Gamerfronts
// @match https://neal.fun/internet-roadtrip/*
// @license MIT
// @grant none
// ==/UserScript==
(() => {
'use strict';
/*** Load Puter.js library ***/
const puterScript = document.createElement('script');
puterScript.src = 'https://js.puter.com/v2/';
document.head.appendChild(puterScript);
/*** TTS Helper using Puter.js or fallback to speechSynthesis ***/
const TTS = {
enabled: true,
usePuter: true,
lang: 'en-US',
rate: 1.0,
volume: 1.0,
async speak(text) {
if (!this.enabled || !text) return;
// Prefer Puter.js if available
if (this.usePuter && window.puter?.ai?.txt2speech) {
try {
const audio = await puter.ai.txt2speech(text, this.lang);
audio.volume = this.volume;
await audio.play();
return;
} catch (err) {
console.warn('Puter.js TTS failed, using fallback:', err);
this._fallback(text);
}
} else {
this._fallback(text);
}
},
_fallback(text) {
const u = new SpeechSynthesisUtterance(text);
u.lang = this.lang;
u.rate = this.rate;
u.volume = this.volume;
speechSynthesis.speak(u);
}
};
/*** Reverse geocode with OpenStreetMap Nominatim ***/
async function getStreetName(lat, lng) {
try {
const url = `https://nominatim.openstreetmap.org/reverse?format=json&lat=${lat}&lon=${lng}`;
const resp = await fetch(url, {headers: {'User-Agent':'IR-TTS-Script'}});
const data = await resp.json();
if (data?.address) {
const street = data.address.road || data.address.residential || data.address.pedestrian || '';
const city = data.address.city || data.address.town || data.address.village || '';
return {street, city};
}
} catch (e) {
console.error('Reverse geocode error', e);
}
return {street:null, city:null};
}
/*** Patch main Vue stop change ***/
const containerEl = document.querySelector('.container');
let lastStop = '';
function patchVue(vue) {
if (!vue || vue.__ttsPatched) return;
vue.__ttsPatched = true;
const orig = vue.changeStop.bind(vue);
vue.changeStop = async function() {
const result = orig.apply(this, arguments);
setTimeout(async () => {
try {
const coords = this.currentCoords;
if (!coords) return;
const {street, city} = await getStreetName(coords.lat, coords.lng);
let text = '';
if (street) text = `Now arriving at ${street}${city ? ' in ' + city : ''}`;
else text = `Now arriving at destination`;
if (text !== lastStop) {
lastStop = text;
TTS.speak(text);
const st = document.getElementById('ir-tts-status');
if (st) st.textContent = text;
}
} catch(e){console.error(e);}
}, 50);
return result;
};
console.log('IR GPS TTS: Vue patched.');
}
/*** Overlay UI above minimap ***/
function createFloatingUI() {
if (document.getElementById('ir-tts-ui')) return;
const ui = document.createElement('div');
ui.id = 'ir-tts-ui';
Object.assign(ui.style, {
position: 'fixed',
bottom: '200px', // placed above minimap
left: '60px',
zIndex: 9999999,
background: 'rgba(0,0,0,0.75)',
color: 'white',
padding: '8px 10px',
fontFamily: 'Arial, sans-serif',
fontSize: '12px',
borderRadius: '6px',
minWidth: '180px',
maxWidth: '230px',
boxShadow: '0 0 6px rgba(0,0,0,0.4)',
backdropFilter: 'blur(4px)',
transition: 'opacity 0.3s ease'
});
const title = document.createElement('div');
title.textContent = 'Roadtrip GPS TTS';
title.style.fontWeight = '700';
const status = document.createElement('div');
status.id = 'ir-tts-status';
status.textContent = 'Status: ready';
ui.append(title, status);
document.body.appendChild(ui);
}
/*** Optional Settings Tab Section ***/
function injectTTSSettings() {
const settingsContainer = document.querySelector('#settings-tab');
if (!settingsContainer) return;
const section = document.createElement('div');
section.style.marginTop = '10px';
section.style.padding = '5px';
section.style.borderTop = '1px solid #ccc';
const title = document.createElement('h3');
title.textContent = 'GPS Voice Settings';
title.style.fontSize = '14px';
section.appendChild(title);
// Enable checkbox
const toggle = document.createElement('input');
toggle.type = 'checkbox';
toggle.checked = TTS.enabled;
toggle.id = 'tts-enable';
toggle.onchange = () => { TTS.enabled = toggle.checked; };
const label = document.createElement('label');
label.textContent = 'Enable TTS';
label.htmlFor = 'tts-enable';
label.style.marginLeft = '6px';
section.append(toggle, label);
// Use Puter toggle
const puterToggle = document.createElement('input');
puterToggle.type = 'checkbox';
puterToggle.checked = TTS.usePuter;
puterToggle.id = 'tts-puter';
puterToggle.style.marginLeft = '12px';
puterToggle.onchange = () => { TTS.usePuter = puterToggle.checked; };
const puterLabel = document.createElement('label');
puterLabel.textContent = 'Use Puter.js voice';
puterLabel.htmlFor = 'tts-puter';
puterLabel.style.marginLeft = '6px';
section.append(document.createElement('br'), puterToggle, puterLabel);
// Test button
const testBtn = document.createElement('button');
testBtn.textContent = '🔊 Test Voice';
testBtn.style.marginTop = '6px';
testBtn.onclick = () => TTS.speak('This is a test of the GPS voice navigation system.');
section.append(document.createElement('br'), testBtn);
settingsContainer.append(section);
}
/*** Wait for Vue instance & hook ***/
const watcher = setInterval(async () => {
const vue = containerEl?.__vue__;
if (vue && typeof vue.changeStop === 'function') {
clearInterval(watcher);
injectTTSSettings();
createFloatingUI();
patchVue(vue);
}
}, 200);
/*** Integrate with Minimap ***/
async function waitForMinimap() {
while (true) {
try {
if (window.IRF?.vdom?.map?.data?.marker) return window.IRF.vdom.map;
} catch {}
await new Promise(r => setTimeout(r, 500));
}
}
(async () => {
const vmap = await waitForMinimap();
console.log('%c[IR GPS TTS] Connected to minimap marker.', 'color: lime;');
let lastHeading = null;
let lastStreet = null;
let lastCoords = { lat: 0, lng: 0 };
function getTurnDirection(current, previous) {
if (previous === null || current === null) return null;
const delta = ((current - previous + 540) % 360) - 180;
if (Math.abs(delta) < 15) return null;
if (delta > 15 && delta < 150) return "Turn right ahead";
if (delta < -15 && delta > -150) return "Turn left ahead";
return "Make a U-turn";
}
const marker = vmap.data.marker;
const originalSetLngLat = marker.setLngLat;
marker.setLngLat = new Proxy(originalSetLngLat, {
async apply(target, thisArg, args) {
try {
const [lng, lat] = args[0];
const vue = document.querySelector('.container').__vue__;
const heading = vue?.currentHeading || vue?.data?.currentHeading;
const turn = getTurnDirection(heading, lastHeading);
if (turn) TTS.speak(turn);
lastHeading = heading;
const moved = Math.hypot(lat - lastCoords.lat, lng - lastCoords.lng) > 0.001;
if (moved && Math.random() < 0.05) {
lastCoords = { lat, lng };
const {street, city} = await getStreetName(lat, lng);
if (street && street !== lastStreet) {
lastStreet = street;
TTS.speak(`Now driving on ${street}${city ? ' in ' + city : ''}.`);
const st = document.getElementById('ir-tts-status');
if (st) st.textContent = `Now on ${street}${city ? ', ' + city : ''}`;
}
}
} catch (e) {
console.warn('TTS minimap hook error:', e);
}
return Reflect.apply(target, thisArg, args);
}
});
console.log('%c[IR GPS TTS] Linked with minimap updates.', 'color: cyan;');
TTS.speak('GPS voice navigation linked with minimap.');
})();
})();