diff --git a/frontend/public/sounds/vibration-call.mp3 b/frontend/public/sounds/vibration-call.mp3 new file mode 100644 index 0000000..3acdcc1 Binary files /dev/null and b/frontend/public/sounds/vibration-call.mp3 differ diff --git a/frontend/public/sounds/vibration-notification.mp3 b/frontend/public/sounds/vibration-notification.mp3 new file mode 100644 index 0000000..7f7d590 Binary files /dev/null and b/frontend/public/sounds/vibration-notification.mp3 differ diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 8eaefcc..f973a80 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -1381,7 +1381,7 @@ onMounted(() => { } }, 1000) if (isDevelopment) { - void hydrateDevelopmentPhone() + const developmentHydration = hydrateDevelopmentPhone() if (developmentParameters.has('simPickerPreview')) { simPicker.value = { choices: [ @@ -1403,16 +1403,26 @@ onMounted(() => { if (developmentParameters.has('payphonePreview')) { openDevelopmentPayphonePreview() } - if (developmentParameters.has('notificationPreview')) { - window.setTimeout(() => { - notifications.show({ - appId: 'messages', - persistent: true, - route: '/apps/messages', - text: 'You still got that spare alternator?', - title: 'Tommy V', - }) - }, 250) + if ( + developmentParameters.has('notificationPreview') || + developmentParameters.has('mutedNotificationPreview') + ) { + void developmentHydration.then(() => { + if (developmentParameters.has('mutedNotificationPreview')) { + phone.setAlertVolumes(0) + } + window.setTimeout(() => { + notifications.show({ + appId: 'messages', + persistent: !developmentParameters.has( + 'mutedNotificationPreview', + ), + route: '/apps/messages', + text: 'You still got that spare alternator?', + title: 'Tommy V', + }) + }, 250) + }) } } }) diff --git a/frontend/src/stores/calls.test.ts b/frontend/src/stores/calls.test.ts index 232ca38..f47b1dc 100644 --- a/frontend/src/stores/calls.test.ts +++ b/frontend/src/stores/calls.test.ts @@ -2,14 +2,16 @@ import { createPinia, setActivePinia } from 'pinia' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { useCallsStore } from '@/stores/calls' +import { usePhoneStore } from '@/stores/phone' import { nuiCall } from '@/utils/nui' -import { playPhoneTone } from '@/utils/tones' +import { playPhoneTone, playPhoneVibration } from '@/utils/tones' vi.mock('@/utils/nui', () => ({ nuiCall: vi.fn(async () => ({ success: true, data: [] })), })) vi.mock('@/utils/tones', () => ({ playPhoneTone: vi.fn(() => vi.fn()), + playPhoneVibration: vi.fn(() => vi.fn()), })) describe('calls store', () => { @@ -53,6 +55,23 @@ describe('calls store', () => { expect(stop).toHaveBeenCalledOnce() }) + it('loops the vibration alert for incoming calls while globally muted', () => { + const phone = usePhoneStore() + phone.preferences.settings.notificationVolume = 0 + phone.preferences.settings.ringtoneVolume = 0 + const calls = useCallsStore() + + calls.applyCallState({ + direction: 'incoming', + id: 'call-muted', + otherNumber: '1234567890', + startedAt: 1, + state: 'ringing', + }) + + expect(playPhoneVibration).toHaveBeenCalledWith('call', true) + }) + it('clears terminal states and refreshes recents', async () => { const calls = useCallsStore() calls.applyCallState({ diff --git a/frontend/src/stores/calls.ts b/frontend/src/stores/calls.ts index 61ad0ca..e6ab071 100644 --- a/frontend/src/stores/calls.ts +++ b/frontend/src/stores/calls.ts @@ -5,7 +5,11 @@ import { usePhoneStore } from '@/stores/phone' import type { PhoneCall, PhoneContact, RecentCall } from '@/types/phone' import { nuiCall, type NuiResponse } from '@/utils/nui' import type { RingtoneId } from '@/utils/preferences' -import { playPhoneTone, type PhoneToneId } from '@/utils/tones' +import { + playPhoneTone, + playPhoneVibration, + type PhoneToneId, +} from '@/utils/tones' const RINGTONE_TONES: Record = { horizon: 'aurora', @@ -140,11 +144,16 @@ export const useCallsStore = defineStore('calls', () => { stopRingtone = null activeCall.value = call if (call.direction === 'incoming' && call.state === 'ringing') { - stopRingtone = playPhoneTone( - RINGTONE_TONES[phone.preferences.settings.ringtone], - phone.preferences.settings.ringtoneVolume, - true, - ) + const alertsMuted = + phone.preferences.settings.notificationVolume === 0 && + phone.preferences.settings.ringtoneVolume === 0 + stopRingtone = alertsMuted + ? playPhoneVibration('call', true) + : playPhoneTone( + RINGTONE_TONES[phone.preferences.settings.ringtone], + phone.preferences.settings.ringtoneVolume, + true, + ) } if (!['ringing', 'connected'].includes(call.state)) { window.setTimeout(() => { diff --git a/frontend/src/stores/notifications.test.ts b/frontend/src/stores/notifications.test.ts index 59eff5f..d37b92d 100644 --- a/frontend/src/stores/notifications.test.ts +++ b/frontend/src/stores/notifications.test.ts @@ -12,9 +12,11 @@ import { DEFAULT_PHONE_PREFERENCES, type PhonePreferencesV1, } from '@/utils/preferences' +import { playPhoneVibration } from '@/utils/tones' vi.mock('@/utils/tones', () => ({ playPhoneTone: vi.fn(() => vi.fn()), + playPhoneVibration: vi.fn(() => vi.fn()), })) vi.mock('@/utils/nui', () => ({ nuiCall: vi.fn(async () => ({ success: true, data: { revision: 1 } })), @@ -120,6 +122,23 @@ describe('notifications store', () => { expect(notifications.devicePreviews).toEqual([]) }) + it('plays a vibration alert for push notifications while globally muted', () => { + const notifications = useNotificationsStore() + const muted = device('111', (preferences) => { + preferences.settings.notificationVolume = 0 + preferences.settings.ringtoneVolume = 0 + }) + + notifications.show({ + appId: 'messages', + device: muted, + text: 'Muted message', + title: 'Messages', + }) + + expect(playPhoneVibration).toHaveBeenCalledWith('notification', false) + }) + it('suppresses normal notifications during Focus but keeps critical alerts', () => { const phone = usePhoneStore() const notifications = useNotificationsStore() diff --git a/frontend/src/stores/notifications.ts b/frontend/src/stores/notifications.ts index 2777000..2a9241e 100644 --- a/frontend/src/stores/notifications.ts +++ b/frontend/src/stores/notifications.ts @@ -9,7 +9,11 @@ import { DEFAULT_APP_NOTIFICATION_PREFERENCES, type PhonePreferencesV1, } from '@/utils/preferences' -import { playPhoneTone, type PhoneToneId } from '@/utils/tones' +import { + playPhoneTone, + playPhoneVibration, + type PhoneToneId, +} from '@/utils/tones' export type PhoneNotificationDevice = { imei: string @@ -183,13 +187,21 @@ export const useNotificationsStore = defineStore('notifications', () => { preferences.settings.notifications[notification.appId] ?? DEFAULT_APP_NOTIFICATION_PREFERENCES if (appPreferences.sounds || notification.critical) { + const alertsMuted = + preferences.settings.notificationVolume === 0 && + preferences.settings.ringtoneVolume === 0 const sound = notification.sound ?? preferences.settings.notificationSound const volume = notification.critical ? preferences.settings.ringtoneVolume : preferences.settings.notificationVolume stopToneHandles.set( notification.id, - playPhoneTone(sound, volume, !!notification.persistent), + alertsMuted + ? playPhoneVibration( + 'notification', + !!notification.persistent, + ) + : playPhoneTone(sound, volume, !!notification.persistent), ) } diff --git a/frontend/src/utils/tones.test.ts b/frontend/src/utils/tones.test.ts index d9e7d50..24cd5f6 100644 --- a/frontend/src/utils/tones.test.ts +++ b/frontend/src/utils/tones.test.ts @@ -1,13 +1,65 @@ -import { describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { ALARM_SOUND_IDS } from './alarms' -import { phoneToneDuration } from './tones' +import { phoneToneDuration, playPhoneVibration } from './tones' describe('phone tones', () => { + afterEach(() => vi.unstubAllGlobals()) + it('defines a playable duration for every alarm sound', () => { for (const sound of ALARM_SOUND_IDS) { expect(phoneToneDuration(sound)).toBeGreaterThan(0) expect(phoneToneDuration(sound)).toBeLessThanOrEqual(1500) } }) + + it.each([ + ['notification', 'sounds/vibration-notification.mp3', false], + ['call', 'sounds/vibration-call.mp3', true], + ] as const)( + 'plays the %s vibration asset with the requested loop behavior', + (kind, path, loop) => { + const pause = vi.fn() + const play = vi.fn(async () => undefined) + const players: Array<{ + currentTime: number + loop: boolean + pause: () => void + play: () => Promise + preload: string + src: string + volume: number + }> = [] + vi.stubGlobal( + 'Audio', + class { + currentTime = 7 + loop = false + pause = pause + play = play + preload = '' + src: string + volume = 0 + + constructor(src: string) { + this.src = src + players.push(this) + } + }, + ) + + const stop = playPhoneVibration(kind, loop) + + expect(players[0]).toMatchObject({ + loop, + preload: 'auto', + src: expect.stringContaining(path), + volume: 1, + }) + expect(play).toHaveBeenCalledOnce() + stop() + expect(pause).toHaveBeenCalledOnce() + expect(players[0].currentTime).toBe(0) + }, + ) }) diff --git a/frontend/src/utils/tones.ts b/frontend/src/utils/tones.ts index 3eb8d5d..f6f13c3 100644 --- a/frontend/src/utils/tones.ts +++ b/frontend/src/utils/tones.ts @@ -2,6 +2,7 @@ import type { AlarmSoundId } from '@/utils/alarms' import type { NotificationSoundId } from '@/utils/preferences' export type PhoneToneId = AlarmSoundId | NotificationSoundId +export type PhoneVibrationKind = 'call' | 'notification' type ToneVoice = { detune?: number @@ -401,6 +402,11 @@ const TONE_PATTERNS: Record = { }, } +const VIBRATION_SOUND_PATHS: Record = { + call: 'sounds/vibration-call.mp3', + notification: 'sounds/vibration-notification.mp3', +} + export function phoneToneDuration(tone: PhoneToneId): number { return Math.max( ...TONE_PATTERNS[tone].steps.map((step) => step.offsetMs + step.durationMs), @@ -484,3 +490,27 @@ export function playPhoneTone( void context.close() } } + +export function playPhoneVibration( + kind: PhoneVibrationKind, + loop: boolean, +): () => void { + const player = new Audio( + `${import.meta.env.BASE_URL}${VIBRATION_SOUND_PATHS[kind]}`, + ) + let stopped = false + player.loop = loop + player.preload = 'auto' + player.volume = 1 + void player.play().catch((error: unknown) => { + if (!stopped) { + console.error('[Phone audio] Failed to start vibration sound', error) + } + }) + + return () => { + stopped = true + player.pause() + player.currentTime = 0 + } +}