Several behaviours diverged between the web build and the Capacitor wrapper, none of them reproducible on a desktop browser. - Declare VIBRATE. PlayerBoard calls navigator.vibrate() on every cell tap; a WebView app must hold the permission itself, where a browser holds it on the page's behalf. Taps were silent in the APK. - Hold a screen wake lock while numbers remain to be called. Auto-call advances on a timer with no touch input, so a round could run 15 minutes untouched — long enough for the display to sleep and the WebView to throttle the interval. Android drops the lock whenever the page hides, so wake-lock.js re-acquires on visibilitychange. - Handle back through OnBackPressedCallback. Android 16 (targetSdk 36) no longer calls onBackPressed() nor dispatches KEYCODE_BACK. Each open overlay pushes one history entry, so "the WebView can go back" means "an overlay is open": back closes the bingo modal or settings sheet, and only at the root does it confirm before quitting. Browsers get the overlay behaviour too. - Inset content by env(safe-area-inset-*) with viewport-fit=cover. targetSdk 36 forces edge-to-edge, so the WebView painted under the status bar and gesture nav. Also fixes the iOS PWA notch. - Pin the WebView's textZoom to 100 and add a "Cỡ chữ bảng" setting. The player card is a fixed 9-column grid that clips at large system font scales. Pinning the zoom overrides an accessibility control, so the app now owns an equivalent one; the two belong together. - Point the volume rocker at the media stream, so it works before the first clip plays. strings.xml also gains the exit-dialog strings and picks up the diacritics in app_name.
Lô Tô
Web version of Lô tô hội chợ — Vietnamese fairground bingo. Built with SvelteKit (static export). The caller draws numbers one at a time; a host panel manages the game and called numbers are announced aloud in Vietnamese using bundled audio clips. No runtime TTS or server required.
Live: tiennm99.github.io/loto
Android port: android/ — a Capacitor wrapper that bundles this
app into a fully-offline APK.
Features
- Number board — 1–90 grid; called numbers highlight as they are drawn
- Host panel — enable "Chế độ quản trò" (host mode) in settings to reveal the master draw panel inline below your player card
- Vietnamese voice callouts — pre-generated MP3 clips for every number using
Microsoft Edge TTS (
edge-tts); multiple voices selectable in settings; no API key or internet connection needed at runtime - Auto-cross player card — optional automatic crossing of numbers on the player's bingo card as they are called
- Auto-countdown — configurable delay between number draws for unattended calling
- PWA — installable as a progressive web app; works offline after first load
- Static export — no server; deploys to any static host or GitHub Pages
Requirements
Quick Start
git clone https://github.com/tiennm99/loto
cd loto
pnpm install
pnpm dev # dev server at http://localhost:5173
Commands
| Command | Description |
|---|---|
pnpm dev |
Start development server |
pnpm build |
Production build (root base path) |
pnpm build:gh |
Production build for GitHub Pages (/loto base path) |
pnpm preview |
Preview production build locally |
pnpm test |
Run unit tests |
pnpm lint |
ESLint check |
Configuration
Copy .env.example to .env.local and set:
| Variable | Description |
|---|---|
CODESERVER_HOST |
Hostname for code-server reverse-proxy dev (optional) |
CODESERVER_PORT |
Port for code-server proxy (optional) |
When running inside code-server use pnpm dev:codeserver and open
https://<CODESERVER_HOST>/absproxy/<CODESERVER_PORT>/ (use /absproxy/, not
/proxy/ — the latter strips the path prefix and breaks SvelteKit routing).
Architecture
Single-page SvelteKit app, statically exported. No backend.
src/
├── routes/ # Single route: /
├── lib/
│ ├── game-logic.js # Draw state, number pool, called-number tracking
│ ├── master-store.svelte.js # Host panel state (Svelte 5 runes store)
│ ├── settings-store.svelte.js
│ ├── voice.js # Audio manifest loader + clip player
│ ├── player-auto-cross.js # Auto-cross logic
│ ├── MasterPanel.svelte # Host draw UI
│ ├── PlayerBoard.svelte # Bingo card + number grid
│ └── AutoCountdown.svelte # Countdown timer between draws
static/
└── audio/{voiceId}/ # Pre-generated MP3 clips (one per number per voice)
scripts/
└── generate-audio.py # Regenerates audio clips via edge-tts
Game state lives entirely in Svelte 5 rune stores; no server round-trips. Audio manifest is generated once at build prep time — the app reads a JSON manifest on load and plays the appropriate clip for each drawn number.
Regenerating Audio
Voice clips in static/audio/ were generated with
edge-tts (free, no API key). To regenerate
(e.g. to add a new Microsoft vi-* voice):
pip install edge-tts
python3 scripts/generate-audio.py
The script auto-discovers all available vi-* voices and writes an updated manifest.
Deployment
Deployed to GitHub Pages by the deploy-pages job in
.github/workflows/ci.yml on push to main. Build command: pnpm build:gh
(sets /loto base path). Static output in build/.
The same workflow also deploys the base-"" build to Firebase Hosting and
bundles it into the debug APK, so web/ is built once per base path per run.
License
Apache-2.0 — see LICENSE at the repository root.