Major PWA and mobile UI improvements

- Fixed PWA installation on Android by correcting manifest.json icon configuration
- Made UI mobile-friendly with compact layout and sticky record button
- Implemented auto-translation after transcription stops
- Updated branding from 'Voice Translator' to 'Talk2Me' throughout
- Added reverse proxy support with ProxyFix middleware
- Created diagnostic tools for PWA troubleshooting
- Added proper HTTP headers for service worker and manifest
- Improved mobile CSS with responsive design
- Fixed JavaScript bundling with webpack configuration
- Updated service worker cache versioning
- Added comprehensive PWA documentation

These changes ensure the app works properly as a PWA on Android devices
and provides a better mobile user experience.
This commit is contained in:
Adolfo Delorenzo 2025-06-03 12:28:09 -06:00
parent b5f2b53262
commit d818ec7d73
16 changed files with 2813 additions and 50 deletions

54
.env.template Normal file
View File

@ -0,0 +1,54 @@
# Talk2Me Environment Configuration Template
# Copy this file to .env and update with your values
# Flask Configuration
FLASK_ENV=production
SECRET_KEY=your-secret-key-here-change-this
# Security Settings for HTTPS/Reverse Proxy
SESSION_COOKIE_SECURE=true
SESSION_COOKIE_SAMESITE=Lax
PREFERRED_URL_SCHEME=https
# TTS Server Configuration
TTS_SERVER_URL=http://localhost:5050/v1/audio/speech
TTS_API_KEY=your-tts-api-key-here
# Whisper Configuration
WHISPER_MODEL_SIZE=base
WHISPER_DEVICE=auto
# Ollama Configuration
OLLAMA_HOST=http://localhost:11434
OLLAMA_MODEL=gemma3:27b
# Admin Configuration
ADMIN_TOKEN=your-admin-token-here-change-this
# CORS Configuration (comma-separated)
CORS_ORIGINS=https://talk2me.dr74.net,http://localhost:5000
ADMIN_CORS_ORIGINS=https://talk2me.dr74.net
# Rate Limiting
RATE_LIMIT_ENABLED=true
RATE_LIMIT_STORAGE_URL=memory://
# Feature Flags
ENABLE_PUSH_NOTIFICATIONS=true
ENABLE_OFFLINE_MODE=true
ENABLE_STREAMING=true
ENABLE_MULTI_SPEAKER=true
# Logging
LOG_LEVEL=INFO
LOG_FILE=logs/talk2me.log
# Upload Configuration
UPLOAD_FOLDER=/tmp/talk2me_uploads
MAX_CONTENT_LENGTH=52428800
MAX_AUDIO_SIZE=26214400
MAX_JSON_SIZE=1048576
# Worker Configuration (for Gunicorn)
WORKER_CONNECTIONS=1000
WORKER_TIMEOUT=120

155
REVERSE_PROXY.md Normal file
View File

@ -0,0 +1,155 @@
# Nginx Reverse Proxy Configuration for Talk2Me
## Nginx Configuration
Add the following to your Nginx configuration for the domain `talk2me.dr74.net`:
```nginx
server {
listen 443 ssl http2;
server_name talk2me.dr74.net;
# SSL configuration
ssl_certificate /path/to/ssl/cert.pem;
ssl_certificate_key /path/to/ssl/key.pem;
# Security headers
add_header X-Content-Type-Options nosniff;
add_header X-Frame-Options SAMEORIGIN;
add_header X-XSS-Protection "1; mode=block";
add_header Referrer-Policy "no-referrer-when-downgrade";
# Proxy settings
location / {
proxy_pass http://localhost:5000; # Adjust port as needed
proxy_http_version 1.1;
# Important headers for PWA and WebSocket support
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
# WebSocket support
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
# Timeouts for long-running requests
proxy_connect_timeout 60s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
# Buffer settings
proxy_buffering off;
proxy_buffer_size 4k;
proxy_buffers 8 4k;
proxy_busy_buffers_size 8k;
# Disable cache for dynamic content
proxy_cache_bypass 1;
proxy_no_cache 1;
}
# Static files with caching
location /static/ {
proxy_pass http://localhost:5000/static/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
# Cache static files
expires 30d;
add_header Cache-Control "public, immutable";
}
# Service worker needs special handling
location /service-worker.js {
proxy_pass http://localhost:5000/service-worker.js;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
# No cache for service worker
add_header Cache-Control "no-cache, no-store, must-revalidate";
add_header Pragma "no-cache";
expires 0;
}
# Manifest file
location /static/manifest.json {
proxy_pass http://localhost:5000/static/manifest.json;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
# Allow manifest to be cached briefly
expires 1h;
add_header Cache-Control "public";
add_header Content-Type "application/manifest+json";
}
}
# Redirect HTTP to HTTPS
server {
listen 80;
server_name talk2me.dr74.net;
return 301 https://$server_name$request_uri;
}
```
## Flask Application Configuration
Set these environment variables for the Talk2Me application:
```bash
# Add to .env file or set as environment variables
FLASK_ENV=production
SESSION_COOKIE_SECURE=true
SESSION_COOKIE_SAMESITE=Lax
PREFERRED_URL_SCHEME=https
# If using a non-standard port
# SERVER_NAME=talk2me.dr74.net
```
## Testing the Configuration
1. **Check SSL Certificate**:
```bash
curl -I https://talk2me.dr74.net
```
2. **Verify Service Worker**:
```bash
curl https://talk2me.dr74.net/service-worker.js
```
3. **Check Manifest**:
```bash
curl https://talk2me.dr74.net/static/manifest.json
```
4. **Test PWA Installation**:
- Visit https://talk2me.dr74.net in Chrome
- Open Developer Tools (F12)
- Go to Application tab
- Check "Manifest" section for any errors
- Check "Service Workers" section
## Common Issues and Solutions
### Issue: Icons not loading
- Ensure static files are being served correctly
- Check Nginx error logs: `tail -f /var/log/nginx/error.log`
### Issue: Service Worker not registering
- Verify HTTPS is working correctly
- Check browser console for errors
- Ensure service worker scope is correct
### Issue: "Add to Home Screen" not appearing
- Clear browser cache and data
- Ensure all manifest requirements are met
- Check Chrome's PWA criteria in DevTools Lighthouse
### Issue: WebSocket connections failing
- Verify Nginx has WebSocket headers configured
- Check if firewall allows WebSocket connections

31
app.py
View File

@ -32,6 +32,9 @@ load_dotenv()
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
# Import ProxyFix for reverse proxy support
from werkzeug.middleware.proxy_fix import ProxyFix
# Import configuration and secrets management
from config import init_app as init_config
from secrets_manager import init_app as init_secrets
@ -82,6 +85,16 @@ def with_error_boundary(func):
app = Flask(__name__)
# Apply ProxyFix middleware for reverse proxy support
# This ensures the app correctly handles X-Forwarded-* headers from Nginx
app.wsgi_app = ProxyFix(
app.wsgi_app,
x_for=1, # Number of reverse proxies setting X-Forwarded-For
x_proto=1, # Number of reverse proxies setting X-Forwarded-Proto
x_host=1, # Number of reverse proxies setting X-Forwarded-Host
x_prefix=1 # Number of reverse proxies setting X-Forwarded-Prefix
)
# Initialize configuration and secrets management
init_config(app)
init_secrets(app)
@ -348,7 +361,23 @@ def apple_touch_icon_120_precomposed():
# Add this route to your Flask app
@app.route('/service-worker.js')
def service_worker():
return app.send_static_file('service-worker.js')
response = app.send_static_file('service-worker.js')
response.headers['Content-Type'] = 'application/javascript'
response.headers['Service-Worker-Allowed'] = '/'
response.headers['Cache-Control'] = 'no-cache, no-store, must-revalidate'
return response
@app.route('/manifest.json')
@app.route('/static/manifest.json')
def manifest():
response = app.send_static_file('manifest.json')
response.headers['Content-Type'] = 'application/manifest+json'
response.headers['Cache-Control'] = 'public, max-age=3600'
return response
@app.route('/check-pwa-status.html')
def check_pwa_status():
return app.send_static_file('check-pwa-status.html')
# Make sure static files are served properly
app.static_folder = 'static'

168
check-pwa-status.html Normal file
View File

@ -0,0 +1,168 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>PWA Installation Checker</title>
<style>
body {
font-family: Arial, sans-serif;
padding: 20px;
max-width: 800px;
margin: 0 auto;
}
.check {
margin: 10px 0;
padding: 10px;
border-radius: 5px;
}
.pass {
background-color: #d4edda;
color: #155724;
}
.fail {
background-color: #f8d7da;
color: #721c24;
}
.info {
background-color: #d1ecf1;
color: #0c5460;
}
pre {
background: #f4f4f4;
padding: 10px;
border-radius: 5px;
overflow-x: auto;
}
</style>
</head>
<body>
<h1>PWA Installation Status Checker</h1>
<div id="results"></div>
<script>
const results = document.getElementById('results');
function addResult(message, status = 'info') {
const div = document.createElement('div');
div.className = `check ${status}`;
div.innerHTML = message;
results.appendChild(div);
}
// Check HTTPS
if (location.protocol === 'https:' || location.hostname === 'localhost') {
addResult('✅ HTTPS/Localhost: ' + location.protocol + '//' + location.hostname, 'pass');
} else {
addResult('❌ Not HTTPS: PWAs require HTTPS (or localhost)', 'fail');
}
// Check Service Worker support
if ('serviceWorker' in navigator) {
addResult('✅ Service Worker API supported', 'pass');
// Check registration
navigator.serviceWorker.getRegistration().then(reg => {
if (reg) {
addResult('✅ Service Worker registered: ' + reg.scope, 'pass');
addResult('Service Worker state: ' + (reg.active ? 'active' : 'not active'), 'info');
} else {
addResult('❌ No Service Worker registered', 'fail');
}
});
} else {
addResult('❌ Service Worker API not supported', 'fail');
}
// Check manifest
const manifestLink = document.querySelector('link[rel="manifest"]');
if (manifestLink) {
addResult('✅ Manifest link found: ' + manifestLink.href, 'pass');
// Fetch and validate manifest
fetch(manifestLink.href)
.then(response => response.json())
.then(manifest => {
addResult('Manifest loaded successfully', 'info');
// Check required fields
const required = ['name', 'short_name', 'start_url', 'display', 'icons'];
required.forEach(field => {
if (manifest[field]) {
addResult(`✅ Manifest has ${field}: ${JSON.stringify(manifest[field])}`, 'pass');
} else {
addResult(`❌ Manifest missing ${field}`, 'fail');
}
});
// Check icons
if (manifest.icons && manifest.icons.length > 0) {
const has192 = manifest.icons.some(icon => icon.sizes && icon.sizes.includes('192'));
const has512 = manifest.icons.some(icon => icon.sizes && icon.sizes.includes('512'));
if (has192) addResult('✅ Has 192x192 icon', 'pass');
else addResult('❌ Missing 192x192 icon', 'fail');
if (has512) addResult('✅ Has 512x512 icon', 'pass');
else addResult('⚠️ Missing 512x512 icon (recommended)', 'info');
// Check icon purposes
manifest.icons.forEach((icon, i) => {
addResult(`Icon ${i + 1}: ${icon.sizes} - purpose: ${icon.purpose || 'not specified'}`, 'info');
});
}
})
.catch(error => {
addResult('❌ Failed to load manifest: ' + error.message, 'fail');
});
} else {
addResult('❌ No manifest link found in HTML', 'fail');
}
// Check installability
window.addEventListener('beforeinstallprompt', (e) => {
e.preventDefault();
addResult('✅ Browser considers app installable (beforeinstallprompt fired)', 'pass');
// Show install criteria met
const criteria = [
'HTTPS or localhost',
'Valid manifest with required fields',
'Service Worker with fetch handler',
'Icons (192x192 minimum)',
'Not already installed'
];
addResult('<strong>Installation criteria met:</strong><br>' + criteria.join('<br>'), 'info');
});
// Check if already installed
if (window.matchMedia('(display-mode: standalone)').matches) {
addResult('✅ App is already installed (running in standalone mode)', 'pass');
}
// Additional Chrome-specific checks
if (navigator.userAgent.includes('Chrome')) {
addResult('Chrome browser detected - checking Chrome-specific requirements', 'info');
setTimeout(() => {
// If no beforeinstallprompt event fired after 3 seconds
if (!window.installPromptFired) {
addResult('⚠️ beforeinstallprompt event not fired after 3 seconds', 'info');
addResult('Possible reasons:<br>' +
'- App already installed<br>' +
'- User dismissed install prompt recently<br>' +
'- Missing PWA criteria<br>' +
'- Chrome needs a user gesture to show prompt', 'info');
}
}, 3000);
}
// Log all checks completed
setTimeout(() => {
addResult('<br><strong>All checks completed</strong>', 'info');
console.log('PWA Status Check Complete');
}, 4000);
</script>
</body>
</html>

121
diagnose-pwa.py Executable file
View File

@ -0,0 +1,121 @@
#!/usr/bin/env python3
"""
PWA Diagnostic Script for Talk2Me
Checks common PWA installation issues
"""
import requests
import json
import sys
from urllib.parse import urljoin
def check_pwa(base_url):
"""Check PWA requirements for the given URL"""
if not base_url.startswith(('http://', 'https://')):
base_url = 'https://' + base_url
if not base_url.endswith('/'):
base_url += '/'
print(f"Checking PWA for: {base_url}\n")
# Check HTTPS
if not base_url.startswith('https://'):
print("❌ PWA requires HTTPS (except for localhost)")
return
else:
print("✅ HTTPS is enabled")
# Check main page
try:
response = requests.get(base_url, timeout=10)
if response.status_code == 200:
print("✅ Main page loads successfully")
else:
print(f"❌ Main page returned status code: {response.status_code}")
except Exception as e:
print(f"❌ Error loading main page: {e}")
return
# Check manifest
manifest_url = urljoin(base_url, '/static/manifest.json')
print(f"\nChecking manifest at: {manifest_url}")
try:
response = requests.get(manifest_url, timeout=10)
if response.status_code == 200:
print("✅ Manifest file found")
# Parse manifest
try:
manifest = response.json()
print(f" - Name: {manifest.get('name', 'Not set')}")
print(f" - Short name: {manifest.get('short_name', 'Not set')}")
print(f" - Display: {manifest.get('display', 'Not set')}")
print(f" - Start URL: {manifest.get('start_url', 'Not set')}")
print(f" - Icons: {len(manifest.get('icons', []))} defined")
# Check icons
for icon in manifest.get('icons', []):
icon_url = urljoin(base_url, icon['src'])
try:
icon_response = requests.head(icon_url, timeout=5)
if icon_response.status_code == 200:
print(f"{icon['sizes']}: {icon['src']}")
else:
print(f"{icon['sizes']}: {icon['src']} (Status: {icon_response.status_code})")
except:
print(f"{icon['sizes']}: {icon['src']} (Failed to load)")
except json.JSONDecodeError:
print("❌ Manifest is not valid JSON")
else:
print(f"❌ Manifest returned status code: {response.status_code}")
except Exception as e:
print(f"❌ Error loading manifest: {e}")
# Check service worker
sw_url = urljoin(base_url, '/service-worker.js')
print(f"\nChecking service worker at: {sw_url}")
try:
response = requests.get(sw_url, timeout=10)
if response.status_code == 200:
print("✅ Service worker file found")
content_type = response.headers.get('Content-Type', '')
if 'javascript' in content_type:
print("✅ Service worker has correct content type")
else:
print(f"⚠️ Service worker content type: {content_type}")
else:
print(f"❌ Service worker returned status code: {response.status_code}")
except Exception as e:
print(f"❌ Error loading service worker: {e}")
# Check favicon
favicon_url = urljoin(base_url, '/static/icons/favicon.ico')
print(f"\nChecking favicon at: {favicon_url}")
try:
response = requests.head(favicon_url, timeout=5)
if response.status_code == 200:
print("✅ Favicon found")
else:
print(f"⚠️ Favicon returned status code: {response.status_code}")
except Exception as e:
print(f"⚠️ Error loading favicon: {e}")
print("\n" + "="*50)
print("PWA Installation Tips:")
print("1. Clear browser cache and app data")
print("2. Visit the site in Chrome on Android")
print("3. Wait a few seconds for the install prompt")
print("4. Or tap menu (⋮) → 'Add to Home screen'")
print("5. Check Chrome DevTools → Application → Manifest")
print("="*50)
if __name__ == "__main__":
if len(sys.argv) > 1:
url = sys.argv[1]
else:
url = input("Enter the URL to check (e.g., talk2me.dr74.net): ")
check_pwa(url)

1652
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -4,9 +4,10 @@
"description": "Real-time voice translation web application",
"main": "index.js",
"scripts": {
"build": "tsc",
"watch": "tsc --watch",
"dev": "tsc --watch",
"build": "webpack",
"build-tsc": "tsc",
"watch": "webpack --watch",
"dev": "webpack --watch",
"clean": "rm -rf static/js/dist",
"type-check": "tsc --noEmit"
},
@ -20,7 +21,9 @@
"license": "ISC",
"devDependencies": {
"@types/node": "^20.10.0",
"typescript": "^5.3.0"
},
"dependencies": {}
"ts-loader": "^9.5.2",
"typescript": "^5.3.0",
"webpack": "^5.99.9",
"webpack-cli": "^6.0.1"
}
}

View File

@ -0,0 +1,218 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>PWA Status Check - Talk2Me</title>
<style>
body {
font-family: Arial, sans-serif;
padding: 20px;
max-width: 600px;
margin: 0 auto;
}
.status {
padding: 10px;
margin: 10px 0;
border-radius: 5px;
}
.success {
background: #d4edda;
color: #155724;
}
.error {
background: #f8d7da;
color: #721c24;
}
.warning {
background: #fff3cd;
color: #856404;
}
.info {
background: #d1ecf1;
color: #0c5460;
}
button {
padding: 10px 20px;
margin: 10px 0;
font-size: 16px;
cursor: pointer;
}
pre {
background: #f5f5f5;
padding: 10px;
overflow-x: auto;
}
</style>
</head>
<body>
<h1>Talk2Me PWA Status Check</h1>
<div id="results"></div>
<h2>Actions</h2>
<button onclick="testInstall()">Test Install Prompt</button>
<button onclick="clearPWA()">Clear PWA Data</button>
<button onclick="location.reload()">Refresh Page</button>
<script>
const results = document.getElementById('results');
function addResult(message, type = 'info') {
const div = document.createElement('div');
div.className = `status ${type}`;
div.textContent = message;
results.appendChild(div);
}
// Check HTTPS
if (location.protocol === 'https:' || location.hostname === 'localhost') {
addResult('✓ HTTPS enabled', 'success');
} else {
addResult('✗ HTTPS required for PWA', 'error');
}
// Check Service Worker support
if ('serviceWorker' in navigator) {
addResult('✓ Service Worker supported', 'success');
// Check registration
navigator.serviceWorker.getRegistration().then(reg => {
if (reg) {
addResult(`✓ Service Worker registered (scope: ${reg.scope})`, 'success');
if (reg.active) {
addResult('✓ Service Worker is active', 'success');
} else if (reg.installing) {
addResult('⚠ Service Worker is installing', 'warning');
} else if (reg.waiting) {
addResult('⚠ Service Worker is waiting', 'warning');
}
} else {
addResult('✗ Service Worker not registered', 'error');
}
});
} else {
addResult('✗ Service Worker not supported', 'error');
}
// Check manifest
const manifestLink = document.querySelector('link[rel="manifest"]');
if (manifestLink) {
addResult('✓ Manifest link found', 'success');
fetch(manifestLink.href)
.then(r => r.json())
.then(manifest => {
addResult('✓ Manifest loaded successfully', 'success');
// Check required fields
const required = ['name', 'short_name', 'start_url', 'display', 'icons'];
required.forEach(field => {
if (manifest[field]) {
addResult(`✓ Manifest has ${field}`, 'success');
} else {
addResult(`✗ Manifest missing ${field}`, 'error');
}
});
// Check icons
if (manifest.icons && manifest.icons.length > 0) {
const has192 = manifest.icons.some(i => i.sizes && i.sizes.includes('192'));
const has512 = manifest.icons.some(i => i.sizes && i.sizes.includes('512'));
if (has192) addResult('✓ Has 192x192 icon', 'success');
else addResult('✗ Missing 192x192 icon', 'error');
if (has512) addResult('✓ Has 512x512 icon', 'success');
else addResult('⚠ Missing 512x512 icon (recommended)', 'warning');
// Check icon purposes
manifest.icons.forEach((icon, i) => {
if (icon.purpose) {
addResult(`Icon ${i+1}: purpose="${icon.purpose}"`, 'info');
}
});
}
// Show manifest content
const pre = document.createElement('pre');
pre.textContent = JSON.stringify(manifest, null, 2);
results.appendChild(pre);
})
.catch(err => {
addResult(`✗ Failed to load manifest: ${err}`, 'error');
});
} else {
addResult('✗ No manifest link found', 'error');
}
// Check if already installed
if (window.matchMedia('(display-mode: standalone)').matches) {
addResult('✓ App is running in standalone mode (already installed)', 'success');
} else {
addResult('App is running in browser mode', 'info');
}
// Listen for install prompt
let deferredPrompt;
window.addEventListener('beforeinstallprompt', (e) => {
e.preventDefault();
deferredPrompt = e;
addResult('✓ Install prompt is available!', 'success');
addResult('Chrome recognizes this as an installable PWA', 'success');
});
// Check Chrome version
const userAgent = navigator.userAgent;
const chromeMatch = userAgent.match(/Chrome\/(\d+)/);
if (chromeMatch) {
const version = parseInt(chromeMatch[1]);
addResult(`Chrome version: ${version}`, 'info');
if (version < 90) {
addResult('⚠ Chrome version is old, consider updating', 'warning');
}
}
// Test install
function testInstall() {
if (deferredPrompt) {
deferredPrompt.prompt();
deferredPrompt.userChoice.then((choiceResult) => {
if (choiceResult.outcome === 'accepted') {
addResult('✓ User accepted the install prompt', 'success');
} else {
addResult('User dismissed the install prompt', 'warning');
}
deferredPrompt = null;
});
} else {
addResult('No install prompt available. Chrome may not recognize this as installable.', 'error');
addResult('Try: Menu (⋮) → Add to Home screen', 'info');
}
}
// Clear PWA data
function clearPWA() {
if ('serviceWorker' in navigator) {
navigator.serviceWorker.getRegistrations().then(function(registrations) {
for(let registration of registrations) {
registration.unregister();
}
addResult('Service Workers unregistered', 'info');
});
}
if ('caches' in window) {
caches.keys().then(function(names) {
for (let name of names) {
caches.delete(name);
}
addResult('Caches cleared', 'info');
});
}
addResult('PWA data cleared. Reload the page to re-register.', 'info');
}
</script>
</body>
</html>

View File

@ -422,6 +422,41 @@
max-width: 300px;
font-size: 14px;
}
/* Make the entire layout more compact on mobile */
body {
font-size: 14px;
}
/* Reduce spacing on mobile */
.mb-3 {
margin-bottom: 0.5rem !important;
}
.mb-4 {
margin-bottom: 0.75rem !important;
}
/* Compact cards on mobile */
.card {
margin-bottom: 8px !important;
}
/* Hide less important elements on small screens */
.text-muted.small {
font-size: 0.75rem;
}
/* Adjust button sizes */
.btn {
font-size: 0.875rem;
}
/* Make dropdowns more compact */
.form-select {
font-size: 0.875rem;
padding: 0.25rem 0.5rem;
}
}
/* Streaming translation styles */

View File

@ -714,6 +714,21 @@ function initApp(): void {
language: data.detected_language || sourceLanguage.value,
timestamp: new Date().toISOString()
} as TranscriptionRecord);
// Automatically trigger translation
setTimeout(async () => {
statusIndicator.textContent = 'Automatically translating...';
statusIndicator.classList.add('processing');
try {
await performTranslation();
} catch (error) {
console.error('Auto-translation failed:', error);
statusIndicator.textContent = 'Transcription complete - Translation failed';
statusIndicator.classList.remove('processing');
statusIndicator.classList.add('warning');
setTimeout(() => statusIndicator.classList.remove('warning'), 2000);
}
}, 500); // Small delay for better UX
} else {
sourceText.innerHTML = `<p class="text-danger fade-in">Error: ${data.error}</p>`;
statusIndicator.textContent = 'Transcription failed';
@ -756,8 +771,8 @@ function initApp(): void {
}
);
// Translate button click event
translateBtn.addEventListener('click', errorBoundary.wrapAsync(async function() {
// Function to perform translation (extracted from button handler)
const performTranslation = async function(): Promise<void> {
if (!currentSourceText) {
return;
}
@ -794,7 +809,10 @@ function initApp(): void {
}
// No cache hit, proceed with API call
statusIndicator.textContent = 'Translating...';
// Don't update status if already showing 'Automatically translating...'
if (!statusIndicator.textContent?.includes('Automatically translating')) {
statusIndicator.textContent = 'Translating...';
}
// Use streaming if enabled
if (streamingEnabled && navigator.onLine) {
@ -992,12 +1010,19 @@ function initApp(): void {
statusIndicator.textContent = 'Translation failed';
}
}
}, 'translation', async () => {
hideProgress();
hideLoadingOverlay();
translatedText.innerHTML = '<p class="text-danger">Translation failed. Please try again.</p>';
statusIndicator.textContent = 'Translation error - please retry';
}));
};
// Translate button click event (now just calls performTranslation)
translateBtn.addEventListener('click', errorBoundary.wrapAsync(
performTranslation,
'translation',
async () => {
hideProgress();
hideLoadingOverlay();
translatedText.innerHTML = '<p class="text-danger">Translation failed. Please try again.</p>';
statusIndicator.textContent = 'Translation error - please retry';
}
));
// Play source text
playSource.addEventListener('click', function() {
@ -1398,7 +1423,7 @@ function initInstallPrompt(): void {
const installButton = document.createElement('button');
installButton.style.display = 'none';
installButton.classList.add('btn', 'btn-success', 'fixed-bottom', 'm-3');
installButton.innerHTML = 'Install Voice Translator <i class="fas fa-download ml-2"></i>';
installButton.innerHTML = 'Install Talk2Me <i class="fas fa-download ml-2"></i>';
document.body.appendChild(installButton);
window.addEventListener('beforeinstallprompt', (e: Event) => {

View File

@ -1,30 +1,42 @@
{
"name": "Talk2Me",
"short_name": "Translator",
"description": "Translate spoken language between multiple languages with speech input and output",
"short_name": "Talk2Me",
"description": "Real-time voice translation app - translate spoken language instantly",
"start_url": "/",
"scope": "/",
"display": "standalone",
"orientation": "portrait",
"background_color": "#ffffff",
"theme_color": "#007bff",
"icons": [
{
"src": "./static/icons/icon-192x192.png",
"src": "/static/icons/icon-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "./static/icons/icon-512x512.png",
"src": "/static/icons/icon-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"
}
],
"screenshots": [
"shortcuts": [
{
"src": "./static/screenshots/screenshot1.png",
"sizes": "1280x720",
"type": "image/png"
"name": "Start Recording",
"short_name": "Record",
"description": "Start voice recording for translation",
"url": "/?action=record",
"icons": [
{
"src": "/static/icons/icon-192x192.png",
"sizes": "192x192"
}
]
}
]
],
"categories": ["productivity", "utilities", "education"],
"prefer_related_applications": false,
"related_applications": []
}

41
static/pwa-update.js Normal file
View File

@ -0,0 +1,41 @@
// PWA Update Helper
// This script helps force PWA updates on clients
// Force service worker update
if ('serviceWorker' in navigator) {
navigator.serviceWorker.getRegistrations().then(function(registrations) {
for(let registration of registrations) {
registration.unregister().then(function() {
console.log('Service worker unregistered');
});
}
});
// Re-register after a short delay
setTimeout(function() {
navigator.serviceWorker.register('/service-worker.js')
.then(function(registration) {
console.log('Service worker re-registered');
registration.update();
});
}, 1000);
}
// Clear all caches
if ('caches' in window) {
caches.keys().then(function(names) {
for (let name of names) {
caches.delete(name);
console.log('Cache cleared:', name);
}
});
}
// Reload manifest
var link = document.querySelector('link[rel="manifest"]');
if (link) {
link.href = link.href + '?v=' + Date.now();
console.log('Manifest reloaded');
}
console.log('PWA update complete. Please reload the page.');

View File

@ -1,10 +1,11 @@
// Service Worker for Talk2Me PWA
const CACHE_NAME = 'voice-translator-v1';
const CACHE_NAME = 'talk2me-v4'; // Increment version to force update
const ASSETS_TO_CACHE = [
'/',
'/static/manifest.json',
'/static/css/styles.css',
'/static/js/dist/app.js',
'/static/js/dist/app.bundle.js',
'/static/icons/icon-192x192.png',
'/static/icons/icon-512x512.png',
'/static/icons/favicon.ico',

View File

@ -2,21 +2,107 @@
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<title>Talk2Me</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha1/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
<link rel="icon" href="/favicon.ico" sizes="any">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no, viewport-fit=cover">
<title>Talk2Me - Real-time Voice Translation</title>
<!-- Icons for various platforms -->
<link rel="icon" href="/static/icons/favicon.ico" sizes="any">
<link rel="apple-touch-icon" href="/static/icons/apple-icon-180x180.png">
<link rel="apple-touch-icon" sizes="120x120" href="/static/icons/apple-icon-120x120.png">
<link rel="apple-touch-icon" sizes="152x152" href="/static/icons/apple-icon-152x152.png">
<link rel="apple-touch-icon" sizes="180x180" href="/static/icons/apple-icon-180x180.png">
<link rel="apple-touch-icon" sizes="167x167" href="/static/icons/apple-icon-167x167.png">
<link rel="apple-touch-icon" sizes="180x180" href="/static/icons/apple-icon-180x180.png">
<style>
body {
padding-top: 20px;
padding-bottom: 20px;
padding-top: 10px;
padding-bottom: 10px;
background-color: #f8f9fa;
}
/* Mobile-first approach */
@media (max-width: 768px) {
.container {
padding-left: 10px;
padding-right: 10px;
}
h1 {
font-size: 1.5rem;
margin-bottom: 0.5rem !important;
}
.card {
margin-bottom: 10px !important;
}
.card-body {
padding: 0.75rem !important;
}
.card-header {
padding: 0.5rem 0.75rem !important;
}
.card-header h5 {
font-size: 1rem;
margin-bottom: 0;
}
.text-display {
min-height: 60px !important;
max-height: 100px;
overflow-y: auto;
padding: 10px !important;
margin-bottom: 10px !important;
font-size: 0.9rem;
}
.language-select {
padding: 5px 10px !important;
font-size: 0.9rem;
margin-bottom: 10px !important;
}
.btn-action {
padding: 5px 10px !important;
font-size: 0.875rem;
margin: 2px !important;
}
.record-btn {
width: 60px !important;
height: 60px !important;
font-size: 24px !important;
margin: 10px auto !important;
}
.status-indicator {
font-size: 0.8rem !important;
margin-top: 5px !important;
}
/* Hide speaker toolbar on mobile by default */
#speakerToolbar {
position: fixed;
bottom: 70px;
left: 0;
right: 0;
z-index: 100;
border-radius: 0 !important;
}
#conversationView {
position: fixed;
bottom: 0;
left: 0;
right: 0;
top: 50%;
z-index: 99;
border-radius: 15px 15px 0 0 !important;
margin: 0 !important;
}
}
.record-btn {
width: 80px;
height: 80px;
@ -91,19 +177,39 @@
font-style: italic;
color: #6c757d;
}
/* Ensure record button area is always visible on mobile */
@media (max-width: 768px) {
.record-section {
position: sticky;
bottom: 0;
background: white;
padding: 10px 0;
box-shadow: 0 -2px 10px rgba(0,0,0,0.1);
z-index: 50;
margin-left: -10px;
margin-right: -10px;
padding-left: 10px;
padding-right: 10px;
}
}
</style>
<!-- PWA Meta Tags -->
<meta name="description" content="Translate spoken language between multiple languages with speech input and output">
<meta name="description" content="Real-time voice translation app - translate spoken language instantly">
<meta name="theme-color" content="#007bff">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="Translator">
<meta name="apple-mobile-web-app-title" content="Talk2Me">
<meta name="application-name" content="Talk2Me">
<meta name="msapplication-TileColor" content="#007bff">
<meta name="msapplication-TileImage" content="/static/icons/icon-192x192.png">
<!-- PWA Icons and Manifest -->
<link rel="manifest" href="/static/manifest.json">
<link rel="icon" type="image/png" href="/static/icons/icon-192x192.png">
<link rel="apple-touch-icon" href="/static/icons/apple-icon-180x180.png">
<link rel="icon" type="image/png" sizes="192x192" href="/static/icons/icon-192x192.png">
<link rel="icon" type="image/png" sizes="512x512" href="/static/icons/icon-512x512.png">
<!-- Apple Splash Screens -->
<link rel="apple-touch-startup-image" href="/static/splash/apple-splash-2048-2732.png" media="(device-width: 1024px) and (device-height: 1366px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)">
@ -199,7 +305,7 @@
</div>
</div>
<div class="text-center">
<div class="text-center record-section">
<button id="recordBtn" class="btn btn-primary record-btn">
<i class="fas fa-microphone"></i>
</button>
@ -211,12 +317,13 @@
<i class="fas fa-sync"></i> Active: <span id="activeRequests">0</span>
</small>
</div>
</div>
<div class="text-center mt-3">
<button id="translateBtn" class="btn btn-success" disabled>
<i class="fas fa-language"></i> Translate
</button>
<div class="mt-2">
<button id="translateBtn" class="btn btn-outline-secondary btn-sm" disabled title="Translation happens automatically after transcription">
<i class="fas fa-redo"></i> Re-translate
</button>
<small class="text-muted d-block mt-1">Translation happens automatically after transcription</small>
</div>
</div>
<div class="mt-3">
@ -399,6 +506,6 @@
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha1/dist/js/bootstrap.bundle.min.js"></script>
<script src="/static/js/dist/app.js"></script>
<script src="/static/js/dist/app.bundle.js"></script>
</body>
</html>

121
validate-pwa.html Normal file
View File

@ -0,0 +1,121 @@
<!DOCTYPE html>
<html>
<head>
<title>PWA Validation - Talk2Me</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
body { font-family: Arial, sans-serif; padding: 20px; }
.status { padding: 10px; margin: 10px 0; border-radius: 5px; }
.success { background: #d4edda; color: #155724; }
.error { background: #f8d7da; color: #721c24; }
.info { background: #d1ecf1; color: #0c5460; }
img { max-width: 100px; height: auto; margin: 10px; }
</style>
</head>
<body>
<h1>Talk2Me PWA Validation</h1>
<h2>Manifest Check</h2>
<div id="manifest-status"></div>
<h2>Icon Check</h2>
<div id="icon-status"></div>
<h2>Service Worker Check</h2>
<div id="sw-status"></div>
<h2>Installation Test</h2>
<button id="install-btn" style="display:none; padding: 10px 20px; font-size: 16px;">Install Talk2Me</button>
<div id="install-status"></div>
<script>
// Check manifest
fetch('/static/manifest.json')
.then(res => res.json())
.then(manifest => {
const status = document.getElementById('manifest-status');
status.innerHTML = `
<div class="status success">✓ Manifest loaded successfully</div>
<div class="status info">Name: ${manifest.name}</div>
<div class="status info">Short Name: ${manifest.short_name}</div>
<div class="status info">Icons: ${manifest.icons.length} defined</div>
`;
// Check icons
const iconStatus = document.getElementById('icon-status');
manifest.icons.forEach(icon => {
const img = new Image();
img.onload = () => {
const div = document.createElement('div');
div.className = 'status success';
div.innerHTML = `✓ ${icon.src} (${icon.sizes}) - ${icon.purpose}`;
iconStatus.appendChild(div);
iconStatus.appendChild(img);
};
img.onerror = () => {
const div = document.createElement('div');
div.className = 'status error';
div.innerHTML = `✗ ${icon.src} (${icon.sizes}) - Failed to load`;
iconStatus.appendChild(div);
};
img.src = icon.src;
img.style.maxWidth = '50px';
});
})
.catch(err => {
document.getElementById('manifest-status').innerHTML =
`<div class="status error">✗ Failed to load manifest: ${err.message}</div>`;
});
// Check service worker
if ('serviceWorker' in navigator) {
navigator.serviceWorker.getRegistration()
.then(reg => {
const status = document.getElementById('sw-status');
if (reg) {
status.innerHTML = `
<div class="status success">✓ Service Worker is registered</div>
<div class="status info">Scope: ${reg.scope}</div>
<div class="status info">State: ${reg.active ? 'Active' : 'Not Active'}</div>
`;
} else {
status.innerHTML = '<div class="status error">✗ Service Worker not registered</div>';
}
})
.catch(err => {
document.getElementById('sw-status').innerHTML =
`<div class="status error">✗ Service Worker error: ${err.message}</div>`;
});
} else {
document.getElementById('sw-status').innerHTML =
'<div class="status error">✗ Service Workers not supported</div>';
}
// Check install prompt
let deferredPrompt;
window.addEventListener('beforeinstallprompt', (e) => {
e.preventDefault();
deferredPrompt = e;
document.getElementById('install-btn').style.display = 'block';
document.getElementById('install-status').innerHTML =
'<div class="status success">✓ App is installable</div>';
});
document.getElementById('install-btn').addEventListener('click', async () => {
if (deferredPrompt) {
deferredPrompt.prompt();
const { outcome } = await deferredPrompt.userChoice;
document.getElementById('install-status').innerHTML +=
`<div class="status info">User ${outcome} the install</div>`;
deferredPrompt = null;
}
});
// Check if already installed
if (window.matchMedia('(display-mode: standalone)').matches) {
document.getElementById('install-status').innerHTML =
'<div class="status success">✓ App is already installed</div>';
}
</script>
</body>
</html>

23
webpack.config.js Normal file
View File

@ -0,0 +1,23 @@
const path = require('path');
module.exports = {
entry: './static/js/src/app.ts',
module: {
rules: [
{
test: /\.tsx?$/,
use: 'ts-loader',
exclude: /node_modules/,
},
],
},
resolve: {
extensions: ['.tsx', '.ts', '.js'],
},
output: {
filename: 'app.bundle.js',
path: path.resolve(__dirname, 'static/js/dist'),
},
mode: 'production',
devtool: 'source-map',
};