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:
1 parent
d71a26831a
commit
76ea1d0d79
6 files changed
+67
-20
No files matched your search
+34
-9
@@ -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
@@ -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>
|
||||
@@ -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";
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
Reference in new issue
Block a user