diff --git a/CHANGELOG.md b/CHANGELOG.md index a84f79e..f158065 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ - Include the selected message as quoted HTML and plain text in replies so email clients can collapse and expand the previous content. +- Restore notification sounds on iPhone by unlocking a local audio player during the first + interaction and reusing it for mail and toast sounds. ## 0.1.15 diff --git a/app/lib/notification-sounds.ts b/app/lib/notification-sounds.ts index 28d58c3..7680897 100644 --- a/app/lib/notification-sounds.ts +++ b/app/lib/notification-sounds.ts @@ -1,5 +1,3 @@ -import { tiks } from "@rexa-developer/tiks"; - export type NotificationSound = | "incoming-email" | "outgoing-email" @@ -18,7 +16,21 @@ export type ToastSoundType = | "success" | "warning"; +const SOUND_VOLUME = 0.55; +const UNLOCK_SOURCE = "/sounds/unlock.wav"; +const UNLOCK_EVENTS = ["touchend", "click", "keydown"] as const; +const SOUND_SOURCES: Record = { + "incoming-email": "/sounds/incoming-email.wav", + "outgoing-email": "/sounds/outgoing-email.wav", + "toast-error": "/sounds/toast-error.wav", + "toast-information": "/sounds/toast-information.wav", + "toast-success": "/sounds/toast-success.wav", + "toast-warning": "/sounds/toast-warning.wav" +}; + let initialized = false; +let unlockListenersBound = false; +let player: HTMLAudioElement | null = null; export function notificationSoundForToastType( type: ToastSoundType | undefined @@ -40,37 +52,90 @@ export function notificationSoundForToast( } export function initializeNotificationSounds(): void { - try { - if (!initialized) { - tiks.init({ - theme: "soft", - volume: 0.18, - respectReducedMotion: true - }); - initialized = true; - } - } catch { - // Audible feedback must never interrupt application startup. - } + if (initialized || typeof document === "undefined" || typeof window === "undefined") return; + initialized = true; + bindUnlockListeners(); + + document.addEventListener("visibilitychange", () => { + if (document.visibilityState === "visible") resetPlayer(); + }); + window.addEventListener("pageshow", resetPlayer); } export function playNotificationSound(sound: NotificationSound): void { try { initializeNotificationSounds(); - if (sound === "incoming-email") { - tiks.notify(); - } else if (sound === "outgoing-email") { - tiks.swoosh(); - } else if (sound === "toast-success") { - tiks.success(); - } else if (sound === "toast-warning") { - tiks.warning(); - } else if (sound === "toast-error") { - tiks.error(); - } else { - tiks.pop(); - } + if (!player || prefersReducedMotion()) return; + + player.pause(); + player.src = SOUND_SOURCES[sound]; + player.currentTime = 0; + player.volume = SOUND_VOLUME; + void player.play().catch(() => { + // The next explicit interaction can unlock playback again. + bindUnlockListeners(); + }); } catch { // Audible feedback must never interrupt the underlying action. } } + +function bindUnlockListeners(): void { + if (unlockListenersBound || typeof document === "undefined") return; + unlockListenersBound = true; + for (const event of UNLOCK_EVENTS) { + document.addEventListener(event, unlockPlayer, { capture: true, passive: true }); + } +} + +function unbindUnlockListeners(): void { + if (!unlockListenersBound || typeof document === "undefined") return; + unlockListenersBound = false; + for (const event of UNLOCK_EVENTS) { + document.removeEventListener(event, unlockPlayer, true); + } +} + +function unlockPlayer(): void { + try { + if (typeof Audio === "undefined") return; + player ??= new Audio(UNLOCK_SOURCE); + player.preload = "auto"; + player.src = UNLOCK_SOURCE; + player.currentTime = 0; + + void player.play().then( + () => { + player?.pause(); + if (player) { + player.currentTime = 0; + player.volume = SOUND_VOLUME; + } + unbindUnlockListeners(); + }, + () => { + // Keep listeners attached so the next interaction can retry. + } + ); + } catch { + // Keep listeners attached so the next interaction can retry. + } +} + +function resetPlayer(): void { + try { + player?.pause(); + } catch { + // Resetting audio must never interrupt returning to the application. + } + player = null; + bindUnlockListeners(); +} + +function prefersReducedMotion(): boolean { + return ( + typeof window !== "undefined" && + typeof window.matchMedia === "function" && + window.matchMedia("(prefers-reduced-motion: reduce)").matches + ); +} diff --git a/package.json b/package.json index 62eaa45..ac23702 100644 --- a/package.json +++ b/package.json @@ -74,7 +74,6 @@ "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-tabs": "^1.1.12", "@radix-ui/react-tooltip": "^1.2.7", - "@rexa-developer/tiks": "^0.3.0", "@tiptap/extension-link": "^3.27.3", "@tiptap/extension-placeholder": "^3.27.3", "@tiptap/react": "^3.27.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 643df97..a8ae33d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -56,9 +56,6 @@ importers: '@radix-ui/react-tooltip': specifier: ^1.2.7 version: 1.2.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@rexa-developer/tiks': - specifier: ^0.3.0 - version: 0.3.0(react@19.2.7) '@tiptap/extension-link': specifier: ^3.27.3 version: 3.27.3(@tiptap/core@3.27.3(@tiptap/pm@3.27.3))(@tiptap/pm@3.27.3) @@ -1355,17 +1352,6 @@ packages: '@radix-ui/rect@1.1.2': resolution: {integrity: sha512-xnXE7wG13PI+cxieVssYXlQJuYVRhH9NBoxt3KNwzghDIA69GMm7d4wXRouHIYjE+KvS6U/MsMO73NdS2MH9ZA==} - '@rexa-developer/tiks@0.3.0': - resolution: {integrity: sha512-7tW/lb53Nr8U0dn9ZlWOvDXtsZ7fK3dZN90Xvp/S4AkjruopuP8992+3KhFCrIQ7bXFEsaVFUZsm/+NC3ufA1w==} - peerDependencies: - react: '>=18.0.0' - vue: '>=3.0.0' - peerDependenciesMeta: - react: - optional: true - vue: - optional: true - '@rolldown/pluginutils@1.0.0-beta.27': resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==} @@ -4103,10 +4089,6 @@ snapshots: '@radix-ui/rect@1.1.2': {} - '@rexa-developer/tiks@0.3.0(react@19.2.7)': - optionalDependencies: - react: 19.2.7 - '@rolldown/pluginutils@1.0.0-beta.27': {} '@rollup/rollup-android-arm-eabi@4.62.2': diff --git a/public/sounds/incoming-email.wav b/public/sounds/incoming-email.wav new file mode 100644 index 0000000..e49bebd Binary files /dev/null and b/public/sounds/incoming-email.wav differ diff --git a/public/sounds/outgoing-email.wav b/public/sounds/outgoing-email.wav new file mode 100644 index 0000000..66e09e7 Binary files /dev/null and b/public/sounds/outgoing-email.wav differ diff --git a/public/sounds/toast-error.wav b/public/sounds/toast-error.wav new file mode 100644 index 0000000..3c924a5 Binary files /dev/null and b/public/sounds/toast-error.wav differ diff --git a/public/sounds/toast-information.wav b/public/sounds/toast-information.wav new file mode 100644 index 0000000..09cfc85 Binary files /dev/null and b/public/sounds/toast-information.wav differ diff --git a/public/sounds/toast-success.wav b/public/sounds/toast-success.wav new file mode 100644 index 0000000..496cc54 Binary files /dev/null and b/public/sounds/toast-success.wav differ diff --git a/public/sounds/toast-warning.wav b/public/sounds/toast-warning.wav new file mode 100644 index 0000000..1c5a140 Binary files /dev/null and b/public/sounds/toast-warning.wav differ diff --git a/public/sounds/unlock.wav b/public/sounds/unlock.wav new file mode 100644 index 0000000..ba30b2c Binary files /dev/null and b/public/sounds/unlock.wav differ diff --git a/scripts/build-pwa.mjs b/scripts/build-pwa.mjs index df82734..8be6164 100644 --- a/scripts/build-pwa.mjs +++ b/scripts/build-pwa.mjs @@ -6,6 +6,7 @@ const allowedPrecachePaths = [ /^\/assets\//, /^\/fonts\//, /^\/icons\//, + /^\/sounds\//, /^\/logo\.svg$/, /^\/manifest\.webmanifest$/, /^\/offline\.html$/ diff --git a/scripts/generate-notification-sounds.mjs b/scripts/generate-notification-sounds.mjs new file mode 100644 index 0000000..36f8de6 --- /dev/null +++ b/scripts/generate-notification-sounds.mjs @@ -0,0 +1,82 @@ +import { mkdir, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const sampleRate = 22_050; +const outputDirectory = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "../public/sounds" +); + +const sounds = { + "incoming-email": [ + { from: 660, to: 660, duration: 90 }, + { gap: 45 }, + { from: 880, to: 880, duration: 150 } + ], + "outgoing-email": [{ from: 420, to: 920, duration: 180 }], + "toast-error": [{ from: 260, to: 170, duration: 170 }], + "toast-information": [{ from: 700, to: 700, duration: 100 }], + "toast-success": [ + { from: 520, to: 520, duration: 75 }, + { gap: 35 }, + { from: 780, to: 780, duration: 120 } + ], + "toast-warning": [ + { from: 440, to: 440, duration: 65 }, + { gap: 45 }, + { from: 440, to: 440, duration: 65 } + ], + unlock: [{ gap: 30 }] +}; + +function renderPcm(segments) { + const samples = []; + for (const segment of segments) { + const sampleCount = Math.ceil(((segment.duration ?? segment.gap ?? 0) / 1000) * sampleRate); + for (let index = 0; index < sampleCount; index += 1) { + if ("gap" in segment) { + samples.push(0); + continue; + } + + const progress = index / Math.max(1, sampleCount - 1); + const frequency = segment.from + (segment.to - segment.from) * progress; + const seconds = index / sampleRate; + const fade = Math.min(1, progress * 14, (1 - progress) * 10); + samples.push(Math.sin(2 * Math.PI * frequency * seconds) * fade * 0.24); + } + } + return samples; +} + +function encodeWav(samples) { + const bytesPerSample = 2; + const buffer = Buffer.alloc(44 + samples.length * bytesPerSample); + buffer.write("RIFF", 0); + buffer.writeUInt32LE(buffer.length - 8, 4); + buffer.write("WAVE", 8); + buffer.write("fmt ", 12); + buffer.writeUInt32LE(16, 16); + buffer.writeUInt16LE(1, 20); + buffer.writeUInt16LE(1, 22); + buffer.writeUInt32LE(sampleRate, 24); + buffer.writeUInt32LE(sampleRate * bytesPerSample, 28); + buffer.writeUInt16LE(bytesPerSample, 32); + buffer.writeUInt16LE(16, 34); + buffer.write("data", 36); + buffer.writeUInt32LE(samples.length * bytesPerSample, 40); + + for (let index = 0; index < samples.length; index += 1) { + const value = Math.max(-1, Math.min(1, samples[index])); + buffer.writeInt16LE(Math.round(value * 32_767), 44 + index * bytesPerSample); + } + return buffer; +} + +await mkdir(outputDirectory, { recursive: true }); +await Promise.all( + Object.entries(sounds).map(([name, segments]) => + writeFile(path.join(outputDirectory, `${name}.wav`), encodeWav(renderPcm(segments))) + ) +); diff --git a/scripts/test-pwa.mjs b/scripts/test-pwa.mjs index 583175b..3103453 100644 --- a/scripts/test-pwa.mjs +++ b/scripts/test-pwa.mjs @@ -55,6 +55,8 @@ try { }); assert.ok(cacheState.names.some((name) => name.startsWith("hqbase-pwa-"))); assert.ok(cacheState.urls.includes("/offline.html")); + assert.ok(cacheState.urls.includes("/sounds/incoming-email.wav")); + assert.ok(cacheState.urls.includes("/sounds/unlock.wav")); assert.equal( cacheState.urls.some((url) => url.startsWith("/api/")), false diff --git a/test/unit/app/notification-sounds.test.ts b/test/unit/app/notification-sounds.test.ts index 27c8439..2a88685 100644 --- a/test/unit/app/notification-sounds.test.ts +++ b/test/unit/app/notification-sounds.test.ts @@ -1,16 +1,6 @@ import { describe, expect, it, vi } from "vitest"; -const tiks = vi.hoisted(() => ({ - error: vi.fn(), - init: vi.fn(), - notify: vi.fn(), - pop: vi.fn(), - success: vi.fn(), - swoosh: vi.fn(), - warning: vi.fn() -})); - -vi.mock("@rexa-developer/tiks", () => ({ tiks })); +const audioInstances: MockAudio[] = []; import { initializeNotificationSounds, @@ -20,7 +10,15 @@ import { } from "@/lib/notification-sounds"; describe("notification sounds", () => { - it("maps mail and toast events to quiet, non-blocking synthesized sounds", () => { + it("maps events and plays local audio after an explicit interaction", async () => { + const documentTarget = Object.assign(new EventTarget(), { visibilityState: "visible" }); + const windowTarget = Object.assign(new EventTarget(), { + matchMedia: vi.fn(() => ({ matches: false })) + }); + vi.stubGlobal("document", documentTarget); + vi.stubGlobal("window", windowTarget); + vi.stubGlobal("Audio", MockAudio); + expect(notificationSoundForToastType("success")).toBe("toast-success"); expect(notificationSoundForToastType("warning")).toBe("toast-warning"); expect(notificationSoundForToastType("error")).toBe("toast-error"); @@ -31,29 +29,44 @@ describe("notification sounds", () => { initializeNotificationSounds(); initializeNotificationSounds(); + expect(audioInstances).toHaveLength(0); + + documentTarget.dispatchEvent(new Event("touchend")); + await Promise.resolve(); + expect(audioInstances).toHaveLength(1); + const audio = audioInstances[0]; + if (!audio) throw new Error("Expected the interaction to create an audio player."); + expect(audio.src).toBe("/sounds/unlock.wav"); + expect(audio.play).toHaveBeenCalledOnce(); + playNotificationSound("incoming-email"); + expect(audio.src).toBe("/sounds/incoming-email.wav"); playNotificationSound("outgoing-email"); + expect(audio.src).toBe("/sounds/outgoing-email.wav"); playNotificationSound("toast-success"); + expect(audio.src).toBe("/sounds/toast-success.wav"); playNotificationSound("toast-warning"); + expect(audio.src).toBe("/sounds/toast-warning.wav"); playNotificationSound("toast-error"); + expect(audio.src).toBe("/sounds/toast-error.wav"); playNotificationSound("toast-information"); + expect(audio.src).toBe("/sounds/toast-information.wav"); - expect(tiks.init).toHaveBeenCalledOnce(); - expect(tiks.init).toHaveBeenCalledWith({ - theme: "soft", - volume: 0.18, - respectReducedMotion: true - }); - expect(tiks.notify).toHaveBeenCalledOnce(); - expect(tiks.swoosh).toHaveBeenCalledOnce(); - expect(tiks.success).toHaveBeenCalledOnce(); - expect(tiks.warning).toHaveBeenCalledOnce(); - expect(tiks.error).toHaveBeenCalledOnce(); - expect(tiks.pop).toHaveBeenCalledOnce(); - - tiks.error.mockImplementationOnce(() => { - throw new Error("Audio unavailable"); - }); - expect(() => playNotificationSound("toast-error")).not.toThrow(); + expect(audio.play).toHaveBeenCalledTimes(7); + expect(audio.volume).toBe(0.55); }); }); + +class MockAudio { + currentTime = 0; + pause = vi.fn(); + play = vi.fn(() => Promise.resolve()); + preload = ""; + src: string; + volume = 1; + + constructor(src = "") { + this.src = src; + audioInstances.push(this); + } +} diff --git a/test/unit/scripts/pwa-build.test.mjs b/test/unit/scripts/pwa-build.test.mjs index f869919..6ac0ba0 100644 --- a/test/unit/scripts/pwa-build.test.mjs +++ b/test/unit/scripts/pwa-build.test.mjs @@ -30,6 +30,7 @@ describe("PWA build contract", () => { it("allows only public shell assets into the precache", () => { expect(isAllowedPrecacheUrl("/assets/app-abc.js")).toBe(true); expect(isAllowedPrecacheUrl("/offline.html")).toBe(true); + expect(isAllowedPrecacheUrl("/sounds/incoming-email.wav")).toBe(true); expect(isAllowedPrecacheUrl("/api/me")).toBe(false); expect(isAllowedPrecacheUrl("/api/messages/123/attachment")).toBe(false); expect(isAllowedPrecacheUrl("/setup")).toBe(false);