ENH - add muted vibration alerts

Play the supplied notification vibration asset for muted push banners and loop the supplied call vibration asset for muted incoming calls. Remove the synthesized vibration tone, add playback coverage, and provide a development preview for the muted notification path.
This commit is contained in:
Leon.Schmidt
2026-08-17 15:30:53 +02:00
parent e58f1b6c18
commit 1b453e3456
9 changed files with 173 additions and 22 deletions
Binary file not shown.
Binary file not shown.
+21 -11
View File
@@ -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)
})
}
}
})
+20 -1
View File
@@ -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({
+15 -6
View File
@@ -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<RingtoneId, PhoneToneId> = {
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(() => {
+19
View File
@@ -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()
+14 -2
View File
@@ -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),
)
}
+54 -2
View File
@@ -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<void>
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)
},
)
})
+30
View File
@@ -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<PhoneToneId, TonePattern> = {
},
}
const VIBRATION_SOUND_PATHS: Record<PhoneVibrationKind, string> = {
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
}
}