mirror of
https://github.com/tiennm99/loto.git
synced 2026-10-13 12:15:02 +00:00
feat(android): close behaviour gaps between the APK and the browser
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.
This commit is contained in:
1 parent
c6c839fd95
commit
3bb6b3d631
15 files changed
+790
-13
No files matched your search
@@ -3,6 +3,7 @@
|
||||
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
android:enableOnBackInvokedCallback="true"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
@@ -38,6 +39,12 @@
|
||||
<!-- No INTERNET permission: app is fully offline. All assets bundled in
|
||||
APK; WebView serves from https://localhost which uses loopback. -->
|
||||
|
||||
<!-- Haptic feedback on cell taps. A WebView app must declare this itself
|
||||
for navigator.vibrate() to work — Chrome holds the permission on the
|
||||
browser's behalf, which is why the web build needs no equivalent.
|
||||
Normal permission: no runtime prompt, no data-safety impact. -->
|
||||
<uses-permission android:name="android.permission.VIBRATE" />
|
||||
|
||||
<!-- Mark hardware as optional so emulators (BlueStacks, NoxPlayer, AVDs)
|
||||
don't get filtered out. BlueStacks reports `faketouch` instead of
|
||||
`touchscreen` and lacks real telephony/GPS hardware. -->
|
||||
|
||||
@@ -1,5 +1,74 @@
|
||||
package com.miti99.loto;
|
||||
|
||||
import android.media.AudioManager;
|
||||
import android.os.Bundle;
|
||||
import android.webkit.WebView;
|
||||
import androidx.activity.OnBackPressedCallback;
|
||||
import androidx.appcompat.app.AlertDialog;
|
||||
import com.getcapacitor.Bridge;
|
||||
import com.getcapacitor.BridgeActivity;
|
||||
|
||||
public class MainActivity extends BridgeActivity {}
|
||||
public class MainActivity extends BridgeActivity {
|
||||
|
||||
@Override
|
||||
public void onCreate(Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
|
||||
WebView webView = webView();
|
||||
if (webView != null) {
|
||||
// The player card is a fixed 9-column grid, so the system font
|
||||
// scale (users routinely run 130-200%) clips two-digit numbers
|
||||
// out of their cells. Pinning the zoom is only defensible
|
||||
// because the app ships its own size control in Settings
|
||||
// ("Cỡ chữ bảng") as the accessible replacement.
|
||||
webView.getSettings().setTextZoom(100);
|
||||
}
|
||||
|
||||
// Calling numbers aloud is the app's whole job, so the volume rocker
|
||||
// should reach the media stream even before the first clip plays.
|
||||
setVolumeControlStream(AudioManager.STREAM_MUSIC);
|
||||
|
||||
// Android 16 (targetSdk 36) no longer calls onBackPressed() and no
|
||||
// longer dispatches KEYCODE_BACK, so back has to come through the
|
||||
// dispatcher. Each open overlay pushes one history entry (see
|
||||
// web/src/lib/overlay-history.js), which makes "the WebView can go
|
||||
// back" mean exactly "an overlay is open".
|
||||
getOnBackPressedDispatcher()
|
||||
.addCallback(
|
||||
this,
|
||||
new OnBackPressedCallback(true) {
|
||||
@Override
|
||||
public void handleOnBackPressed() {
|
||||
WebView wv = webView();
|
||||
if (wv != null && wv.canGoBack()) {
|
||||
wv.goBack();
|
||||
return;
|
||||
}
|
||||
confirmExit();
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/** Null-safe accessor — the bridge is built during super.onCreate(). */
|
||||
private WebView webView() {
|
||||
Bridge bridge = getBridge();
|
||||
return bridge == null ? null : bridge.getWebView();
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-call runs untouched for minutes at a time, so a stray back press
|
||||
* at the root would otherwise drop the caller out of a live round.
|
||||
*/
|
||||
private void confirmExit() {
|
||||
// Explicit AppCompat dialog theme: the activity runs on
|
||||
// AppTheme.NoActionBarLaunch (parent Theme.SplashScreen), which is
|
||||
// not guaranteed to be AppCompat-derived at dialog time.
|
||||
new AlertDialog.Builder(this, R.style.AppTheme_ExitDialog)
|
||||
.setTitle(R.string.exit_title)
|
||||
.setMessage(R.string.exit_message)
|
||||
.setNegativeButton(R.string.exit_cancel, null)
|
||||
.setPositiveButton(R.string.exit_confirm, (dialog, which) -> finish())
|
||||
.show();
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,13 @@
|
||||
<?xml version='1.0' encoding='utf-8'?>
|
||||
<resources>
|
||||
<string name="app_name">Lo To</string>
|
||||
<string name="title_activity_main">Lo To</string>
|
||||
<string name="app_name">Lô tô</string>
|
||||
<string name="title_activity_main">Lô tô</string>
|
||||
<string name="package_name">com.miti99.loto</string>
|
||||
<string name="custom_url_scheme">com.miti99.loto</string>
|
||||
|
||||
<!-- Exit confirmation, shown when back is pressed with no overlay open. -->
|
||||
<string name="exit_title">Thoát Lô tô?</string>
|
||||
<string name="exit_message">Ván đang chơi sẽ được lưu lại.</string>
|
||||
<string name="exit_confirm">Thoát</string>
|
||||
<string name="exit_cancel">Ở lại</string>
|
||||
</resources>
|
||||
@@ -17,6 +17,21 @@
|
||||
|
||||
|
||||
<style name="AppTheme.NoActionBarLaunch" parent="Theme.SplashScreen">
|
||||
<!-- API 31+ system splash. Colour follows light/dark via
|
||||
values-night/colors.xml. -->
|
||||
<item name="windowSplashScreenBackground">@color/splash_background</item>
|
||||
<item name="windowSplashScreenAnimatedIcon">@mipmap/ic_launcher_foreground</item>
|
||||
<!-- Hand the activity a normal AppCompat theme once the splash is
|
||||
done; Theme.SplashScreen is not meant to stay applied, and
|
||||
AppCompat dialogs need an AppCompat-derived theme. -->
|
||||
<item name="postSplashScreenTheme">@style/AppTheme.NoActionBar</item>
|
||||
<!-- Compat path for API < 31; see drawable/splash.xml. -->
|
||||
<item name="android:background">@drawable/splash</item>
|
||||
</style>
|
||||
|
||||
<!-- Exit-confirmation dialog. Declared with an explicit AppCompat parent
|
||||
because the activity theme (Theme.SplashScreen) is not guaranteed to
|
||||
be AppCompat-derived, and AppCompat's AlertDialog requires one.
|
||||
DayNight so the dialog follows the app's light/dark setting. -->
|
||||
<style name="AppTheme.ExitDialog" parent="Theme.AppCompat.DayNight.Dialog.Alert" />
|
||||
</resources>
|
||||
@@ -39,6 +39,42 @@ html {
|
||||
}
|
||||
body {
|
||||
background-color: transparent;
|
||||
/* Android 15+ (targetSdk 35+) forces edge-to-edge, so the WebView paints
|
||||
under the status bar and gesture nav. Inset the content here rather than
|
||||
on the page container: `html` carries the background colour, so padding
|
||||
on `body` keeps the background full-bleed while content clears the bars.
|
||||
`fixed inset-0` overlays are viewport-relative and deliberately
|
||||
unaffected — their backdrops should still cover the whole screen.
|
||||
Zero-width where there are no insets (desktop, older Android). */
|
||||
padding-top: env(safe-area-inset-top);
|
||||
padding-right: env(safe-area-inset-right);
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
padding-left: env(safe-area-inset-left);
|
||||
}
|
||||
|
||||
/* Board number sizes, scaled by the in-app "Cỡ chữ bảng" setting.
|
||||
These replace Tailwind's text-* utilities on the grid cells: the wrapper
|
||||
pins the WebView's textZoom to 100 (a 9-column fixed grid clips badly at
|
||||
200% system font), so the app has to own the size control itself.
|
||||
Player card and master board keep separate rungs — the player card has a
|
||||
md: step the master board never had, and merging them would silently
|
||||
enlarge the master board on desktop. */
|
||||
.board-num {
|
||||
font-size: calc(1.25rem * var(--board-text-scale, 1));
|
||||
}
|
||||
.master-num {
|
||||
font-size: calc(1.25rem * var(--board-text-scale, 1));
|
||||
}
|
||||
@media (min-width: 640px) {
|
||||
.board-num,
|
||||
.master-num {
|
||||
font-size: calc(1.5rem * var(--board-text-scale, 1));
|
||||
}
|
||||
}
|
||||
@media (min-width: 768px) {
|
||||
.board-num {
|
||||
font-size: calc(1.875rem * var(--board-text-scale, 1));
|
||||
}
|
||||
}
|
||||
|
||||
/* Equal-width grid cells */
|
||||
|
||||
+6
-1
@@ -2,7 +2,12 @@
|
||||
<html lang="vi" class="h-full antialiased">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<!-- viewport-fit=cover is required for env(safe-area-inset-*) to report
|
||||
real values under Android's forced edge-to-edge and on notched iOS. -->
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1, viewport-fit=cover"
|
||||
/>
|
||||
<meta name="format-detection" content="telephone=no" />
|
||||
<title>Lô tô — Hội chợ TN1</title>
|
||||
<meta
|
||||
|
||||
@@ -42,6 +42,7 @@
|
||||
import MasterEmptyState from "$lib/MasterEmptyState.svelte";
|
||||
import { settings } from "$lib/settings-store.svelte.js";
|
||||
import { cancelPlayback, playNumber } from "$lib/voice.js";
|
||||
import { setWakeLock } from "$lib/wake-lock.js";
|
||||
|
||||
let heroEl = $state(/** @type {HTMLDivElement | null} */ (null));
|
||||
let autoRunning = $state(false);
|
||||
@@ -107,6 +108,20 @@
|
||||
// toggled off mid-clip) so audio doesn't outlive the panel.
|
||||
$effect(() => () => cancelPlayback());
|
||||
|
||||
// Hold the screen awake while numbers are still to be called. Auto-call
|
||||
// advances on a timer with no touch input, so without this the display
|
||||
// sleeps mid-round and the WebView throttles the interval.
|
||||
//
|
||||
// Keyed on `remaining` rather than `hasGame`: `hasGame` never returns to
|
||||
// false once a round starts, which would pin the screen on for a finished
|
||||
// board. Player-only mode never mounts this panel — those screens stay
|
||||
// awake from the user's own cell taps. Android drops the lock whenever the
|
||||
// page hides, so a backgrounded app holds nothing.
|
||||
$effect(() => {
|
||||
setWakeLock(masterState.remaining.length > 0);
|
||||
return () => setWakeLock(false);
|
||||
});
|
||||
|
||||
// Scroll the "Số vừa xổ" hero into view on each new draw so the host
|
||||
// doesn't have to chase it on a phone. Gated by a user-interaction flag
|
||||
// so reloading with persisted state doesn't yank the page on mount.
|
||||
@@ -282,9 +297,9 @@
|
||||
<!-- Token: cream inner with a sky-blue ring when called,
|
||||
gray-ringed and dim when uncalled. -->
|
||||
<div
|
||||
class="flex items-center justify-center
|
||||
class="master-num flex items-center justify-center
|
||||
w-[82%] h-[82%] rounded-full
|
||||
text-xl sm:text-2xl font-black tabular-nums
|
||||
font-black tabular-nums
|
||||
border-[3px] transition-all
|
||||
{isCalled
|
||||
? 'border-sky-600 dark:border-sky-400 bg-amber-50 dark:bg-amber-100 text-sky-700 dark:text-sky-700'
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
saveManualUnticks,
|
||||
} from "$lib/game-logic.js";
|
||||
import { masterState } from "$lib/master-store.svelte.js";
|
||||
import { pushOverlay } from "$lib/overlay-history.js";
|
||||
import { applyMasterCalls } from "$lib/player-auto-cross.js";
|
||||
import { settings } from "$lib/settings-store.svelte.js";
|
||||
import { cancelPlayback, playBingo, playWaiting } from "$lib/voice.js";
|
||||
@@ -213,16 +214,23 @@
|
||||
dismissToast();
|
||||
});
|
||||
|
||||
// Bingo modal: window-level Escape so it works regardless of which
|
||||
// element holds focus (the inline backdrop button rarely does).
|
||||
// Bingo modal dismissal. Window-level Escape so it works regardless of
|
||||
// which element holds focus (the inline backdrop button rarely does), plus
|
||||
// a history sentinel so the Android back gesture closes the modal instead
|
||||
// of leaving the app.
|
||||
$effect(() => {
|
||||
if (!showCongrats) return;
|
||||
const dispose = pushOverlay(() => (showCongrats = false));
|
||||
/** @param {KeyboardEvent} e */
|
||||
const onKey = (e) => {
|
||||
if (e.key === "Escape") showCongrats = false;
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
return () => {
|
||||
window.removeEventListener("keydown", onKey);
|
||||
// No-op when back already popped the sentinel; pops it otherwise.
|
||||
dispose();
|
||||
};
|
||||
});
|
||||
|
||||
// Auto-cross on master draws (only in "both" mode). Reads
|
||||
@@ -448,9 +456,8 @@
|
||||
aria-label="Số {num}{isCrossed ? ', đã đánh dấu' : ''}{isWaitingCell ? ', đang chờ' : ''}"
|
||||
aria-pressed={isCrossed}
|
||||
onclick={() => handleCellClick(row, col)}
|
||||
class="tan-tan-num relative flex items-center justify-center
|
||||
class="tan-tan-num board-num relative flex items-center justify-center
|
||||
aspect-[3/4] sm:aspect-[3/5]
|
||||
text-xl sm:text-2xl md:text-3xl
|
||||
border border-slate-400/50 dark:border-slate-600/40
|
||||
transition-all select-none cursor-pointer active:scale-90
|
||||
focus:outline-none focus:ring-2 focus:ring-inset focus:ring-rose-400
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
<script>
|
||||
import { VOICES } from "$lib/audio-manifest.js";
|
||||
import { pushOverlay } from "$lib/overlay-history.js";
|
||||
import {
|
||||
BOARD_TEXT_SCALES,
|
||||
resetSettings,
|
||||
saveSettings,
|
||||
settings,
|
||||
@@ -29,6 +31,14 @@
|
||||
["dark", "Tối"],
|
||||
]);
|
||||
|
||||
// Parallel to BOARD_TEXT_SCALES.
|
||||
const BOARD_TEXT_SCALE_LABELS = /** @type {const} */ ([
|
||||
"Nhỏ",
|
||||
"Vừa",
|
||||
"Lớn",
|
||||
"Rất lớn",
|
||||
]);
|
||||
|
||||
const MODES = /** @type {const} */ ([
|
||||
["player", "Người chơi"],
|
||||
["master", "Quản trò"],
|
||||
@@ -60,6 +70,12 @@
|
||||
saveSettings();
|
||||
}
|
||||
|
||||
/** @param {number} s */
|
||||
function pickBoardTextScale(s) {
|
||||
settings.boardTextScale = s;
|
||||
saveSettings();
|
||||
}
|
||||
|
||||
/** @param {"player"|"master"|"both"} m */
|
||||
function pickMode(m) {
|
||||
settings.mode = m;
|
||||
@@ -109,15 +125,22 @@
|
||||
|
||||
// Escape-to-close while open. Window listener avoids relying on focus
|
||||
// staying inside the modal — the dialog div may not receive keydown
|
||||
// when the trigger button still holds focus on initial open.
|
||||
// when the trigger button still holds focus on initial open. The history
|
||||
// sentinel makes the Android back gesture close the sheet rather than
|
||||
// leave the app.
|
||||
$effect(() => {
|
||||
if (!open) return;
|
||||
const dispose = pushOverlay(close);
|
||||
/** @param {KeyboardEvent} e */
|
||||
const onKey = (e) => {
|
||||
if (e.key === "Escape") close();
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
return () => {
|
||||
window.removeEventListener("keydown", onKey);
|
||||
// No-op when back already popped the sentinel; pops it otherwise.
|
||||
dispose();
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -208,6 +231,36 @@
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<!-- Board number size. The Android wrapper pins the WebView's textZoom
|
||||
so the system font setting can't clip the fixed 9-column grid;
|
||||
this is the in-app replacement for that control. -->
|
||||
<fieldset class="mb-5">
|
||||
<legend
|
||||
class="text-sm font-semibold text-slate-700 dark:text-slate-200 mb-2"
|
||||
>
|
||||
Cỡ chữ bảng
|
||||
</legend>
|
||||
<p class="text-sm text-slate-500 dark:text-slate-400 mb-2">
|
||||
Cỡ số trên bảng người chơi và bảng quản trò
|
||||
</p>
|
||||
<div class="grid grid-cols-4 gap-2">
|
||||
{#each BOARD_TEXT_SCALES as scale, i (scale)}
|
||||
{@const selected = settings.boardTextScale === scale}
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={selected}
|
||||
onclick={() => pickBoardTextScale(scale)}
|
||||
class="px-2 py-2 rounded-lg border-2 text-sm font-medium transition-all
|
||||
{selected
|
||||
? 'border-rose-600 dark:border-rose-400 bg-rose-50 dark:bg-rose-950/40 text-rose-700 dark:text-rose-300'
|
||||
: 'border-slate-200 dark:border-slate-600 text-slate-600 dark:text-slate-300 hover:border-slate-300 dark:hover:border-slate-500'}"
|
||||
>
|
||||
{BOARD_TEXT_SCALE_LABELS[i]}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
{#snippet switchRow(label, isOn, onToggle)}
|
||||
<!-- Whole row is the switch — role/tabindex/click here, not on the
|
||||
inner pill, so tapping the label text also toggles. -->
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* Makes the back gesture close the topmost overlay instead of leaving the app.
|
||||
*
|
||||
* Each open overlay pushes one sentinel history entry. A back press pops it,
|
||||
* `popstate` fires, and we close the newest overlay. Only when nothing is left
|
||||
* to pop does back reach the native layer, which then asks before exiting
|
||||
* (see `MainActivity.handleOnBackPressed`).
|
||||
*
|
||||
* Browsers get the same behaviour for free — browser back closes the modal
|
||||
* rather than leaving the page.
|
||||
*
|
||||
* SvelteKit interop: its client router gates its own `popstate` handler on
|
||||
* `event.state?.[HISTORY_INDEX]`. Our sentinel state deliberately omits that
|
||||
* key, so the router ignores these entries and no navigation is triggered.
|
||||
* Don't spread `history.state` into the sentinel — that would reintroduce the
|
||||
* key and hand our events to the router.
|
||||
*
|
||||
* @module lib/overlay-history
|
||||
*/
|
||||
|
||||
/** @typedef {{ id: number, close: () => void }} OverlayEntry */
|
||||
|
||||
/** Open overlays, oldest first. Closing is LIFO. */
|
||||
/** @type {OverlayEntry[]} */
|
||||
const stack = [];
|
||||
|
||||
let seq = 0;
|
||||
|
||||
/**
|
||||
* Number of `popstate` events to swallow because we caused them ourselves via
|
||||
* `history.back()` in `dispose()`. Without this, closing by button or Escape
|
||||
* would pop the sentinel AND re-run `close()`.
|
||||
*/
|
||||
let suppress = 0;
|
||||
|
||||
let listening = false;
|
||||
|
||||
function onPopState() {
|
||||
if (suppress > 0) {
|
||||
suppress--;
|
||||
return;
|
||||
}
|
||||
const entry = stack.pop();
|
||||
entry?.close();
|
||||
}
|
||||
|
||||
/**
|
||||
* Installed on first use and left in place for the page lifetime. Removing it
|
||||
* when the stack empties would strand a pending suppressed `popstate`, leaking
|
||||
* a `suppress` count into the next overlay and swallowing a real back press.
|
||||
*/
|
||||
function startListening() {
|
||||
if (listening) return;
|
||||
window.addEventListener("popstate", onPopState);
|
||||
listening = true;
|
||||
}
|
||||
|
||||
/** @param {OverlayEntry} entry */
|
||||
function dispose(entry) {
|
||||
const i = stack.indexOf(entry);
|
||||
// Already gone — closed by a back press, which ran `close()` itself.
|
||||
if (i === -1) return;
|
||||
stack.splice(i, 1);
|
||||
suppress++;
|
||||
history.back();
|
||||
}
|
||||
|
||||
/**
|
||||
* Register an open overlay. Call on open; call the returned function when the
|
||||
* overlay closes by any other route (button, Escape, backdrop tap) so the
|
||||
* sentinel is popped and history stays balanced.
|
||||
*
|
||||
* Nested overlays close LIFO. Each overlay owns exactly one history entry, so
|
||||
* disposing a non-top overlay still pops one entry and the counts stay level.
|
||||
*
|
||||
* @param {() => void} close — closes this overlay; must be idempotent-safe
|
||||
* @returns {() => void} dispose
|
||||
*/
|
||||
export function pushOverlay(close) {
|
||||
if (typeof window === "undefined") return () => {};
|
||||
const entry = { id: ++seq, close };
|
||||
stack.push(entry);
|
||||
startListening();
|
||||
history.pushState({ lotoOverlay: entry.id }, "");
|
||||
return () => dispose(entry);
|
||||
}
|
||||
|
||||
/** Test-only teardown so module state doesn't leak across cases. */
|
||||
export function _resetOverlayHistoryForTest() {
|
||||
stack.length = 0;
|
||||
suppress = 0;
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
_resetOverlayHistoryForTest,
|
||||
pushOverlay,
|
||||
} from "./overlay-history.js";
|
||||
|
||||
// happy-dom's History does not reliably emit popstate for back(), so we drive
|
||||
// the event by hand. That also keeps these tests about our own bookkeeping
|
||||
// rather than the polyfill's fidelity.
|
||||
function firePopState() {
|
||||
window.dispatchEvent(new Event("popstate"));
|
||||
}
|
||||
|
||||
let back;
|
||||
let pushState;
|
||||
|
||||
beforeEach(() => {
|
||||
_resetOverlayHistoryForTest();
|
||||
back = vi.spyOn(history, "back").mockImplementation(() => {});
|
||||
pushState = vi.spyOn(history, "pushState").mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("overlay-history", () => {
|
||||
it("pushes one history entry per overlay", () => {
|
||||
pushOverlay(() => {});
|
||||
expect(pushState).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("omits SvelteKit's history index so the router ignores the entry", () => {
|
||||
pushOverlay(() => {});
|
||||
const state = pushState.mock.calls[0][0];
|
||||
// SvelteKit gates its popstate handler on this key; if we ever leak it,
|
||||
// back presses would trigger router navigation instead of closing a modal.
|
||||
expect(Object.keys(state)).toEqual(["lotoOverlay"]);
|
||||
});
|
||||
|
||||
it("closes the overlay on back", () => {
|
||||
const close = vi.fn();
|
||||
pushOverlay(close);
|
||||
firePopState();
|
||||
expect(close).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("programmatic close pops the sentinel without re-closing", () => {
|
||||
const close = vi.fn();
|
||||
const dispose = pushOverlay(close);
|
||||
|
||||
dispose();
|
||||
expect(back).toHaveBeenCalledTimes(1);
|
||||
|
||||
// The back() above produces a popstate; it must be swallowed.
|
||||
firePopState();
|
||||
expect(close).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("closes nested overlays LIFO", () => {
|
||||
const closeA = vi.fn();
|
||||
const closeB = vi.fn();
|
||||
pushOverlay(closeA);
|
||||
pushOverlay(closeB);
|
||||
|
||||
firePopState();
|
||||
expect(closeB).toHaveBeenCalledTimes(1);
|
||||
expect(closeA).not.toHaveBeenCalled();
|
||||
|
||||
firePopState();
|
||||
expect(closeA).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("disposing a non-top overlay keeps the remaining one closable", () => {
|
||||
const closeA = vi.fn();
|
||||
const closeB = vi.fn();
|
||||
const disposeA = pushOverlay(closeA);
|
||||
pushOverlay(closeB);
|
||||
|
||||
disposeA();
|
||||
expect(back).toHaveBeenCalledTimes(1);
|
||||
firePopState(); // swallowed — caused by disposeA
|
||||
expect(closeB).not.toHaveBeenCalled();
|
||||
|
||||
firePopState(); // real back press
|
||||
expect(closeB).toHaveBeenCalledTimes(1);
|
||||
expect(closeA).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("disposing twice is harmless", () => {
|
||||
const close = vi.fn();
|
||||
const dispose = pushOverlay(close);
|
||||
dispose();
|
||||
dispose();
|
||||
expect(back).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("a back press after the overlay already closed is a no-op", () => {
|
||||
const close = vi.fn();
|
||||
const dispose = pushOverlay(close);
|
||||
dispose();
|
||||
firePopState(); // swallowed
|
||||
expect(() => firePopState()).not.toThrow();
|
||||
expect(close).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -12,6 +12,13 @@ const STORAGE_KEY = "loto_settings";
|
||||
const HEX6 = /^#[0-9a-fA-F]{6}$/;
|
||||
const VALID_THEMES = /** @type {const} */ (["auto", "light", "dark"]);
|
||||
const VALID_MODES = /** @type {const} */ (["player", "master", "both"]);
|
||||
/**
|
||||
* Board number sizes, as a multiplier on the responsive base size.
|
||||
* The Android wrapper pins the WebView's textZoom to 100 so the system font
|
||||
* setting can't break the fixed 9-column grid — this is the in-app
|
||||
* replacement for that control, so the rungs need to reach genuinely large.
|
||||
*/
|
||||
export const BOARD_TEXT_SCALES = /** @type {const} */ ([0.9, 1, 1.15, 1.3]);
|
||||
/** Hard cap on stored settings JSON to keep poisoned origins from
|
||||
* stalling the UI on mount. Real settings serialize to ~200 bytes. */
|
||||
const MAX_STORAGE_BYTES = 8_192;
|
||||
@@ -35,6 +42,8 @@ export const DEFAULT_SETTINGS = Object.freeze({
|
||||
voiceWaitingNumber: false,
|
||||
/** Active voice id; matches an entry in audio manifest. */
|
||||
voice: DEFAULT_VOICE,
|
||||
/** Multiplier on board number size; one of BOARD_TEXT_SCALES. */
|
||||
boardTextScale: 1,
|
||||
});
|
||||
|
||||
export const settings = $state({ ...DEFAULT_SETTINGS });
|
||||
@@ -70,6 +79,13 @@ function validSpeed(v) {
|
||||
function validVoiceId(v) {
|
||||
return typeof v === "string" && VOICE_IDS.has(v) ? v : null;
|
||||
}
|
||||
/** @param {unknown} v */
|
||||
function validBoardTextScale(v) {
|
||||
return typeof v === "number" &&
|
||||
BOARD_TEXT_SCALES.includes(/** @type {any} */ (v))
|
||||
? v
|
||||
: null;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* DOM apply */
|
||||
@@ -112,9 +128,18 @@ function applyTheme() {
|
||||
}
|
||||
}
|
||||
|
||||
function applyBoardTextScale() {
|
||||
if (typeof document === "undefined") return;
|
||||
document.documentElement.style.setProperty(
|
||||
"--board-text-scale",
|
||||
String(settings.boardTextScale),
|
||||
);
|
||||
}
|
||||
|
||||
function applyAll() {
|
||||
applyEmptyCellColor();
|
||||
applyTheme();
|
||||
applyBoardTextScale();
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
@@ -157,6 +182,9 @@ export function loadSettings() {
|
||||
DEFAULT_SETTINGS.voiceWaitingNumber;
|
||||
settings.voice =
|
||||
validVoiceId(parsed.voice) ?? DEFAULT_SETTINGS.voice;
|
||||
settings.boardTextScale =
|
||||
validBoardTextScale(parsed.boardTextScale) ??
|
||||
DEFAULT_SETTINGS.boardTextScale;
|
||||
}
|
||||
} catch {
|
||||
/* private mode / quota / corrupt JSON — fall back to defaults */
|
||||
@@ -183,5 +211,6 @@ export function resetSettings() {
|
||||
settings.voiceEnabledPlayer = DEFAULT_SETTINGS.voiceEnabledPlayer;
|
||||
settings.voiceWaitingNumber = DEFAULT_SETTINGS.voiceWaitingNumber;
|
||||
settings.voice = DEFAULT_SETTINGS.voice;
|
||||
settings.boardTextScale = DEFAULT_SETTINGS.boardTextScale;
|
||||
saveSettings();
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
BOARD_TEXT_SCALES,
|
||||
DEFAULT_SETTINGS,
|
||||
loadSettings,
|
||||
resetSettings,
|
||||
@@ -52,10 +53,12 @@ beforeEach(() => {
|
||||
"voiceEnabledPlayer",
|
||||
"voiceWaitingNumber",
|
||||
"voice",
|
||||
"boardTextScale",
|
||||
])) {
|
||||
/** @type {any} */ (settings)[k] = /** @type {any} */ (DEFAULT_SETTINGS)[k];
|
||||
}
|
||||
document.documentElement.style.removeProperty("--empty-cell-bg");
|
||||
document.documentElement.style.removeProperty("--board-text-scale");
|
||||
document.documentElement.classList.remove("dark");
|
||||
mockMatchMedia(false);
|
||||
});
|
||||
@@ -272,6 +275,7 @@ describe("settings-store — saveSettings", () => {
|
||||
settings.voiceEnabledPlayer = true;
|
||||
settings.voiceWaitingNumber = true;
|
||||
settings.voice = "nam-minh";
|
||||
settings.boardTextScale = 1.15;
|
||||
saveSettings();
|
||||
const raw = localStorage.getItem(KEY);
|
||||
expect(raw).not.toBeNull();
|
||||
@@ -285,6 +289,7 @@ describe("settings-store — saveSettings", () => {
|
||||
voiceEnabledPlayer: true,
|
||||
voiceWaitingNumber: true,
|
||||
voice: "nam-minh",
|
||||
boardTextScale: 1.15,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -367,3 +372,58 @@ describe("settings-store — applyTheme via load/save", () => {
|
||||
expect(document.documentElement.classList.contains("dark")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("settings-store — boardTextScale", () => {
|
||||
it("defaults to 1", () => {
|
||||
expect(DEFAULT_SETTINGS.boardTextScale).toBe(1);
|
||||
loadSettings();
|
||||
expect(settings.boardTextScale).toBe(1);
|
||||
});
|
||||
|
||||
it("applies the scale as a CSS custom property", () => {
|
||||
settings.boardTextScale = 1.3;
|
||||
saveSettings();
|
||||
expect(
|
||||
document.documentElement.style.getPropertyValue("--board-text-scale"),
|
||||
).toBe("1.3");
|
||||
});
|
||||
|
||||
it("round-trips every allowed rung through storage", () => {
|
||||
for (const scale of BOARD_TEXT_SCALES) {
|
||||
settings.boardTextScale = scale;
|
||||
saveSettings();
|
||||
settings.boardTextScale = 1;
|
||||
loadSettings();
|
||||
expect(settings.boardTextScale).toBe(scale);
|
||||
}
|
||||
});
|
||||
|
||||
it("falls back to the default for a value outside the allowlist", () => {
|
||||
localStorage.setItem(KEY, JSON.stringify({ boardTextScale: 2.5 }));
|
||||
loadSettings();
|
||||
expect(settings.boardTextScale).toBe(1);
|
||||
});
|
||||
|
||||
it("falls back to the default for a non-number", () => {
|
||||
localStorage.setItem(KEY, JSON.stringify({ boardTextScale: "1.15" }));
|
||||
loadSettings();
|
||||
expect(settings.boardTextScale).toBe(1);
|
||||
});
|
||||
|
||||
it("leaves other keys intact when boardTextScale is missing", () => {
|
||||
localStorage.setItem(KEY, JSON.stringify({ theme: "dark" }));
|
||||
loadSettings();
|
||||
expect(settings.theme).toBe("dark");
|
||||
expect(settings.boardTextScale).toBe(1);
|
||||
});
|
||||
|
||||
it("reset restores the default", () => {
|
||||
settings.boardTextScale = 1.3;
|
||||
saveSettings();
|
||||
resetSettings();
|
||||
expect(settings.boardTextScale).toBe(1);
|
||||
expect(
|
||||
document.documentElement.style.getPropertyValue("--board-text-scale"),
|
||||
).toBe("1");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* Screen wake lock for the caller's device.
|
||||
*
|
||||
* Auto-call advances on a timer with no touch input, so a full round (up to
|
||||
* 90 numbers at 1–10s each) can run for 15 minutes without the user ever
|
||||
* touching the screen. Without a lock the display sleeps, the WebView
|
||||
* throttles its timers, and the round stalls.
|
||||
*
|
||||
* Android releases the lock whenever the page hides, so a one-shot
|
||||
* `request()` is dead after the first backgrounding — returning to the app
|
||||
* has to re-acquire. That re-acquire is why this module owns a
|
||||
* `visibilitychange` listener instead of just handing back a sentinel.
|
||||
*
|
||||
* @module lib/wake-lock
|
||||
*/
|
||||
|
||||
/** @type {any} — WakeLockSentinel; lib.dom in this project predates the type. */
|
||||
let sentinel = null;
|
||||
|
||||
/** True while a caller wants the screen held awake. */
|
||||
let wanted = false;
|
||||
|
||||
/**
|
||||
* Guards the async gap inside `acquire()`. A `request()` that resolves after
|
||||
* the caller flipped to `false` must release immediately rather than leak a
|
||||
* lock the UI thinks it dropped. Same idea as `activeToken` in `voice.js`.
|
||||
*/
|
||||
let generation = 0;
|
||||
|
||||
/** Prevents two concurrent `acquire()` calls from each taking a sentinel. */
|
||||
let acquiring = false;
|
||||
|
||||
let listening = false;
|
||||
|
||||
function supported() {
|
||||
return (
|
||||
typeof navigator !== "undefined" &&
|
||||
typeof document !== "undefined" &&
|
||||
!!(/** @type {any} */ (navigator).wakeLock)
|
||||
);
|
||||
}
|
||||
|
||||
function onVisibilityChange() {
|
||||
if (document.visibilityState === "visible") void acquire();
|
||||
}
|
||||
|
||||
function startListening() {
|
||||
if (listening || !supported()) return;
|
||||
document.addEventListener("visibilitychange", onVisibilityChange);
|
||||
listening = true;
|
||||
}
|
||||
|
||||
function stopListening() {
|
||||
if (!listening) return;
|
||||
document.removeEventListener("visibilitychange", onVisibilityChange);
|
||||
listening = false;
|
||||
}
|
||||
|
||||
async function acquire() {
|
||||
if (!supported() || !wanted || sentinel || acquiring) return;
|
||||
acquiring = true;
|
||||
const token = generation;
|
||||
try {
|
||||
const next = await /** @type {any} */ (navigator).wakeLock.request(
|
||||
"screen",
|
||||
);
|
||||
if (token !== generation || !wanted) {
|
||||
// Turned off while we awaited — drop it rather than hold a lock
|
||||
// nobody asked for.
|
||||
void next.release?.();
|
||||
return;
|
||||
}
|
||||
sentinel = next;
|
||||
// The system drops the lock on its own (screen off, battery saver).
|
||||
// Clear our handle so the next visibilitychange can re-acquire.
|
||||
next.addEventListener?.("release", () => {
|
||||
if (sentinel === next) sentinel = null;
|
||||
});
|
||||
} catch {
|
||||
/* hidden page, battery saver, permissions policy — nothing to do */
|
||||
} finally {
|
||||
acquiring = false;
|
||||
}
|
||||
}
|
||||
|
||||
function releaseNow() {
|
||||
const held = sentinel;
|
||||
sentinel = null;
|
||||
if (held) {
|
||||
try {
|
||||
void Promise.resolve(held.release?.()).catch(() => {});
|
||||
} catch {
|
||||
/* already released by the system */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hold or drop the screen wake lock. Idempotent — safe to call from a
|
||||
* reactive effect that re-runs on unrelated state changes.
|
||||
*
|
||||
* No-ops entirely where `navigator.wakeLock` is missing (older WebViews on
|
||||
* minSdk 24 devices).
|
||||
*
|
||||
* @param {boolean} on
|
||||
*/
|
||||
export function setWakeLock(on) {
|
||||
if (on === wanted) return;
|
||||
wanted = on;
|
||||
// Invalidate any in-flight acquire from the previous state.
|
||||
generation++;
|
||||
if (on) {
|
||||
startListening();
|
||||
void acquire();
|
||||
} else {
|
||||
stopListening();
|
||||
releaseNow();
|
||||
}
|
||||
}
|
||||
|
||||
/** Test-only teardown so module state doesn't leak across cases. */
|
||||
export function _resetWakeLockForTest() {
|
||||
wanted = false;
|
||||
generation++;
|
||||
acquiring = false;
|
||||
stopListening();
|
||||
sentinel = null;
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { _resetWakeLockForTest, setWakeLock } from "./wake-lock.js";
|
||||
|
||||
class FakeSentinel {
|
||||
constructor() {
|
||||
this.released = false;
|
||||
/** @type {Record<string, () => void>} */
|
||||
this.listeners = {};
|
||||
}
|
||||
release() {
|
||||
this.released = true;
|
||||
this.listeners.release?.();
|
||||
return Promise.resolve();
|
||||
}
|
||||
/** @param {string} type @param {() => void} fn */
|
||||
addEventListener(type, fn) {
|
||||
this.listeners[type] = fn;
|
||||
}
|
||||
}
|
||||
|
||||
/** @type {FakeSentinel[]} */
|
||||
let sentinels = [];
|
||||
/** @type {() => Promise<any>} */
|
||||
let requestImpl;
|
||||
let requestCount = 0;
|
||||
|
||||
/** @param {"visible" | "hidden"} state */
|
||||
function setVisibility(state) {
|
||||
Object.defineProperty(document, "visibilityState", {
|
||||
configurable: true,
|
||||
get: () => state,
|
||||
});
|
||||
document.dispatchEvent(new Event("visibilitychange"));
|
||||
}
|
||||
|
||||
function grantWakeLock() {
|
||||
Object.defineProperty(navigator, "wakeLock", {
|
||||
configurable: true,
|
||||
value: {
|
||||
request: () => {
|
||||
requestCount++;
|
||||
return requestImpl();
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
sentinels = [];
|
||||
requestCount = 0;
|
||||
requestImpl = () => {
|
||||
const s = new FakeSentinel();
|
||||
sentinels.push(s);
|
||||
return Promise.resolve(s);
|
||||
};
|
||||
grantWakeLock();
|
||||
setVisibility("visible");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
_resetWakeLockForTest();
|
||||
Reflect.deleteProperty(navigator, "wakeLock");
|
||||
});
|
||||
|
||||
describe("wake-lock", () => {
|
||||
it("acquires a screen lock when turned on", async () => {
|
||||
setWakeLock(true);
|
||||
await Promise.resolve();
|
||||
expect(requestCount).toBe(1);
|
||||
expect(sentinels[0].released).toBe(false);
|
||||
});
|
||||
|
||||
it("releases the lock when turned off", async () => {
|
||||
setWakeLock(true);
|
||||
await Promise.resolve();
|
||||
setWakeLock(false);
|
||||
expect(sentinels[0].released).toBe(true);
|
||||
});
|
||||
|
||||
it("is idempotent — repeat calls do not stack locks", async () => {
|
||||
setWakeLock(true);
|
||||
setWakeLock(true);
|
||||
await Promise.resolve();
|
||||
setWakeLock(true);
|
||||
await Promise.resolve();
|
||||
expect(requestCount).toBe(1);
|
||||
});
|
||||
|
||||
it("re-acquires after the system drops the lock and the app returns", async () => {
|
||||
setWakeLock(true);
|
||||
await Promise.resolve();
|
||||
expect(requestCount).toBe(1);
|
||||
|
||||
// Android releases the lock on hide; the sentinel fires "release".
|
||||
sentinels[0].release();
|
||||
setVisibility("hidden");
|
||||
setVisibility("visible");
|
||||
await Promise.resolve();
|
||||
|
||||
expect(requestCount).toBe(2);
|
||||
expect(sentinels[1].released).toBe(false);
|
||||
});
|
||||
|
||||
it("does not re-acquire once turned off", async () => {
|
||||
setWakeLock(true);
|
||||
await Promise.resolve();
|
||||
setWakeLock(false);
|
||||
setVisibility("hidden");
|
||||
setVisibility("visible");
|
||||
await Promise.resolve();
|
||||
expect(requestCount).toBe(1);
|
||||
});
|
||||
|
||||
it("releases a lock that resolves after the caller turned it off", async () => {
|
||||
/** @type {(s: FakeSentinel) => void} */
|
||||
let resolveRequest = () => {};
|
||||
requestImpl = () =>
|
||||
new Promise((resolve) => {
|
||||
resolveRequest = resolve;
|
||||
});
|
||||
|
||||
setWakeLock(true);
|
||||
setWakeLock(false);
|
||||
|
||||
const late = new FakeSentinel();
|
||||
resolveRequest(late);
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
|
||||
// Must not leak a lock the UI already considers dropped.
|
||||
expect(late.released).toBe(true);
|
||||
});
|
||||
|
||||
it("no-ops without navigator.wakeLock", () => {
|
||||
Reflect.deleteProperty(navigator, "wakeLock");
|
||||
expect(() => setWakeLock(true)).not.toThrow();
|
||||
expect(() => setWakeLock(false)).not.toThrow();
|
||||
});
|
||||
|
||||
it("swallows a rejected request", async () => {
|
||||
requestImpl = () => Promise.reject(new Error("battery saver"));
|
||||
setWakeLock(true);
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
expect(() => setWakeLock(false)).not.toThrow();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user