Sound was dead in the app because status 0 is not a failure; and back is a swipe to the RIGHT

Oliver on the first shell build: *"I don't hear any of the games sounds.. daniel the umpire is
synthesized which is horrible and no music is playing on start"* — two independent causes.

- **Opaque fetch.** Capacitor serves the bundle through a custom WKURLSchemeHandler, and `fetch`
  reports those responses as `status: 0, ok: false` with the whole body present and decoding
  perfectly. All three loads in `audio.js` guarded with `if (!r.ok) throw`, so inside the app every
  recorded sound failed at the door: the umpire fell through to the synthesized `say()`, no bed
  started, no sample played. The synthesized sfx kept working, which made it read as a mixing bug
  rather than a loader one. Fixed once, in the shared `bytes()` helper every audio fetch now routes
  through — a status of 0 is the ABSENCE of an HTTP status, so trust it only when there is one and
  a real 404 still falls back. Any future `!r.ok` on a bundle-relative fetch is this same bug.
- **The ring switch.** WKWebView's WebAudio runs in the SoloAmbient session, which the hardware
  silent switch mutes outright — a silent game on a phone that lives on silent, with nothing on
  screen to explain it. AppDelegate now sets AVAudioSession `.playback`, the games category.

Confirmed in the shell: the bed reaches PLAYING, the context is `running` with no gesture at all
(autoplay really is allowed in WKWebView, as LATER predicted), and `say()` never fires. On the web,
a genuinely missing file still falls back — checked both.

And the swipe flips: **right goes back**, the way iOS's own pop gesture goes (*"that's the iOS
way"*). Left now does nothing. Noted in PROJECT.md that on a platform gesture the platform's
convention beats the literal wording of the brief, and that is worth saying up front.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
wuwiandClaude Opus 5 committed 2026-08-20 17:18:47 +02:00
1 parent d71a26831a
commit 76ea1d0d79
6 files changed
+67 -20

No files matched your search

+34 -9
View File
@@ -2582,15 +2582,40 @@ inside a `<script type="module">` is a hard 404 the moment the same file is serv
purpose — iOS already routes these through the system's own vibration setting.
**Untested on hardware.** The simulator has no Taptic Engine, so every weight and gap below is a
guess on paper and needs its own phone round before any of it is assumed good.
- **SWIPE LEFT GOES BACK** in the menus. Every screen but the splash wears exactly one chevron, and
that button already knows where back is for its own screen — so the gesture finds the visible
`.chev` and clicks it rather than keeping a second map of the wizard that would drift the first
time a stage is added. Threshold 60 px, horizontal-dominant so the stats and board screens still
scroll. The `touchend` listener is deliberately **not passive**: a swipe that starts and ends
inside one of the big poster cards also produces that card's click, and `preventDefault` is what
stops the screen answering forward in the same frame the chevron walked it back.
**iOS convention is the opposite** (the system pop gesture drags rightward from the left edge).
Built to the brief as asked; flipping it is the sign on one comparison in `main.js`.
- **SWIPE RIGHT GOES BACK** in the menus — the direction iOS's own pop gesture goes. (Built leftward
first, to the letter of the brief; Oliver flipped it the same hour: *"that's the iOS way"*. The
lesson is cheap and worth keeping: on a platform gesture, the platform's convention beats the
literal wording of the request — say so up front rather than shipping it and flagging it after.)
Every screen but the splash wears exactly one chevron, and that button already knows where back is
for its own screen — so the gesture finds the visible `.chev` and clicks it rather than keeping a
second map of the wizard that would drift the first time a stage is added. Threshold 60 px,
horizontal-dominant so the stats and board screens still scroll. The `touchend` listener is
deliberately **not passive**: a swipe that starts and ends inside one of the big poster cards also
produces that card's click, and `preventDefault` is what stops the screen answering forward in the
same frame the chevron walked it back.
- **SOUND, and it was broken twice over on the first shell build.** Oliver, minutes after it ran:
*"I don't hear any of the games sounds.. daniel the umpire is synthesized which is horrible and no
music is playing on start"*. Two independent causes, both now fixed, both worth knowing before the
next plugin:
1. **`fetch` responses from the app bundle are OPAQUE.** Capacitor serves `capacitor://localhost`
through a custom `WKURLSchemeHandler`, and `fetch` reports those as `status: 0, ok: false` —
with the full body present and decoding perfectly. `audio.js` guarded all three of its loads
with a plain `if (!r.ok) throw`, so inside the app EVERY recorded sound failed at the door: the
umpire fell through to the synthesized `say()`, no bed ever started, no sample ever played.
The synthesized sfx kept working, which made it read as a mixing bug rather than a loader one.
Fixed once, in the shared `bytes()` helper every audio fetch now routes through: **a status of
0 is the absence of an HTTP status, not a failure** — trust the status only when there is one,
so a real 404 still falls back. **Any future `!r.ok` guard on a bundle-relative fetch is this
same bug.** (`stats.js` is fine: its board calls go to the live host over real HTTPS.)
2. **The ring switch muted everything on device.** WKWebView's WebAudio runs in the SoloAmbient
session, which the hardware silent switch kills outright — a completely silent game on a phone
that lives on silent, with nothing on screen to explain it. `AppDelegate` now sets
`AVAudioSession .playback`, the games-and-media category. That is a shell-only fix; the web
build has no say in it, and it is a good part of why the shell is worth having for sound.
Confirmed in the shell after both fixes: the bed reaches `PLAYING`, the context is `running`
**with no gesture at all** (autoplay really is allowed in WKWebView, as LATER predicted), and the
`say()` fallback never fires. On the web, a genuinely missing file still falls back — checked.
- **The app has no server behind it.** Inside the shell the page is served from the bundle
(`capacitor://localhost`), so `fetch('api/scores')` and the netplay socket would have resolved
against the app's own files. `config.NATIVE`/`config.API` and one branch in `net.js` point the
+1 -1
View File
@@ -661,6 +661,6 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat
</div>
</div>
</div>
<script type="module" src="src/main.js?v=296"></script>
<script type="module" src="src/main.js?v=297"></script>
</body>
</html>
+2
View File
@@ -349,6 +349,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = T789785GZK;
INFOPLIST_FILE = App/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
@@ -369,6 +370,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = T789785GZK;
INFOPLIST_FILE = App/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
+9 -1
View File
@@ -1,5 +1,6 @@
import UIKit
import Capacitor
import AVFoundation
@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {
@@ -7,7 +8,14 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
var window: UIWindow?
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
// Override point for customization after application launch.
// PLAY THROUGH THE RING SWITCH. WKWebView's WebAudio runs in the SoloAmbient session by
// default, which the hardware silent switch mutes outright — so a phone that lives on silent
// (most of them) gets a completely silent game and nothing on screen explains why. `.playback`
// is the games-and-media category: audible on silent, and it is what makes the umpire and the
// menu theme reach a real device at all. The web build has no say in this; it is the whole
// reason the shell is worth having for sound. See PROJECT.md, "What the shell buys".
try? AVAudioSession.sharedInstance().setCategory(.playback)
try? AVAudioSession.sharedInstance().setActive(true)
return true
}
+17 -6
View File
@@ -7,6 +7,20 @@ export const audio = () => {
return AC;
};
// EVERY audio file is fetched through here, and the reason is one line of it. Inside the Capacitor
// shell the bundle is served by a custom WKURLSchemeHandler, and `fetch` reports those responses as
// OPAQUE: `status` 0 and `ok` false, even though the bytes are all present and decode perfectly.
// The old guard was a plain `if (!r.ok) throw` — so in the app EVERY recorded sound failed at the
// door: the umpire fell through to the synthesized speech fallback, no bed ever started, and not a
// single sample played, while the synthesized sfx kept working and made it look like a mixing bug
// (Oliver, 2026-08-20, on the first shell build: *"daniel the umpire is synthesized which is
// horrible and no music is playing on start"*). A status of 0 is not a failure, it is the absence
// of an HTTP status — so trust the status only when there IS one, and let a real 404 still throw.
const bytes = url => fetch(url).then(r => {
if (r.status && !r.ok) throw new Error(url + ' ' + r.status);
return r.arrayBuffer();
});
// Umpire lines are pre-recorded files (assets/audio/*.m4a) played via WebAudio —
// works on mobile where speechSynthesis is unreliable. say() remains the fallback.
const VOICE = {};
@@ -48,8 +62,7 @@ export function voice(name, delay = 0, rate = 1) {
function loadVoice(name) {
if (VOICE[name]) return Promise.resolve(VOICE[name]);
return fetch('assets/audio/' + name + '.m4a')
.then(r => { if (!r.ok) throw 0; return r.arrayBuffer(); })
return bytes('assets/audio/' + name + '.m4a')
.then(ab => audio().decodeAudioData(ab))
.then(buf => (VOICE[name] = buf));
}
@@ -119,8 +132,7 @@ export function sfx(name, vol = 1, opts = {}) {
};
if (SFX[name]) return play(SFX[name]);
// a name with an extension is used as-is (weapon-click2.mp3); bare names keep the .wav default
fetch('assets/audio/' + (name.includes('.') ? name : name + '.wav'))
.then(r => { if (!r.ok) throw 0; return r.arrayBuffer(); })
bytes('assets/audio/' + (name.includes('.') ? name : name + '.wav'))
.then(ab => ac.decodeAudioData(ab))
.then(buf => { SFX[name] = buf; play(buf); })
.catch(() => {}); // missing sample = silence, never a thrown error mid-rally
@@ -141,8 +153,7 @@ function loopBed(file, lowcut, vol, loopEnd = 0) {
const ac = audio();
if (beds.has(file)) return;
beds.set(file, null);
fetch('assets/audio/' + file) // full filename: the beds are a mix of .wav and .m4a, see below
.then(r => { if (!r.ok) throw 0; return r.arrayBuffer(); })
bytes('assets/audio/' + file) // full filename: the beds are a mix of .wav and .m4a, see below
.then(ab => ac.decodeAudioData(ab))
.then(buf => {
// RESUME AGAIN, HERE. The gesture that unlocked the context is long gone by now: the menu
+4 -3
View File
@@ -537,7 +537,8 @@ document.getElementById('sBack').addEventListener('click', () => {
});
document.getElementById('bsBack').addEventListener('click', () => go('secret'));
// SWIPE TO GO BACK. Every screen but the splash wears exactly one chevron in its header (.hdr in
// SWIPE TO GO BACK — RIGHTWARD, the way iOS's own pop gesture goes (Oliver, 2026-08-20: *"that's
// the iOS way"*). Every screen but the splash wears exactly one chevron in its header (.hdr in
// index.html) and that button ALREADY knows where back is for its own screen — the wizard's shape
// lives in the handlers above and nowhere else. So the gesture finds the one visible chevron and
// clicks it, rather than keeping a second map that would drift the first time a stage is added.
@@ -553,8 +554,8 @@ intro.addEventListener('touchend', e => {
if (!from || e.touches.length) return;
const t = e.changedTouches[0];
const dx = t.clientX - from.x, dy = t.clientY - from.y;
// leftward, and clearly horizontal: the stats and board screens scroll vertically under the finger
if (dx > -SWIPE_MIN || Math.abs(dy) > Math.abs(dx) * 0.7) return;
// rightward, and clearly horizontal: the stats and board screens scroll vertically under the finger
if (dx < SWIPE_MIN || Math.abs(dy) > Math.abs(dx) * 0.7) return;
const back = [...intro.querySelectorAll('.chev')].find(b => b.offsetParent); // the one on screen
if (!back) return;
// NOT passive, and this is why: a swipe that starts and ends inside one of the big poster cards