mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-09-04 09:13:24 +00:00
ADD - manage custom phone tones
This commit is contained in:
@@ -87,6 +87,7 @@ import { getHairlinePixelStyle } from '@/utils/rendering'
|
||||
import { isTextInputElement } from '@/utils/textInputFocus'
|
||||
import { configurePhoneNumberFormat } from '@/utils/phone'
|
||||
import { consumeEscape } from '@/utils/keyboard'
|
||||
import type { CustomPhoneToneCatalog } from '@/utils/customTones'
|
||||
import { isTrustedRootMessageSource } from '@/utils/windowMessages'
|
||||
import SpringboardView from '@/views/SpringboardView.vue'
|
||||
|
||||
@@ -118,6 +119,7 @@ type AppMessage = {
|
||||
| CustomAppEventData
|
||||
| NavigationEventData
|
||||
| AdminPanelOpenPayload
|
||||
| CustomPhoneToneCatalog
|
||||
}
|
||||
|
||||
type AdminPanelOpenPayload = Required<
|
||||
@@ -750,6 +752,8 @@ function onMessage(event: MessageEvent<AppMessage>): void {
|
||||
adminPanelOpen.value = true
|
||||
} else if (event.data?.type === 'admin:close') {
|
||||
adminPanelOpen.value = false
|
||||
} else if (event.data?.type === 'phone:tones') {
|
||||
phone.setCustomTones(event.data.data)
|
||||
} else if (event.data?.type === 'custom-apps:catalog') {
|
||||
appCatalog.replaceCatalog(event.data.data)
|
||||
const catalogPayload = event.data.data as
|
||||
|
||||
@@ -0,0 +1,809 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
FileAudio,
|
||||
FileCode2,
|
||||
LoaderCircle,
|
||||
LockKeyhole,
|
||||
Pause,
|
||||
Play,
|
||||
Plus,
|
||||
Trash2,
|
||||
Upload,
|
||||
} from 'lucide-vue-next'
|
||||
import { computed, onBeforeUnmount, ref } from 'vue'
|
||||
|
||||
import { useAdminStore } from '@/stores/admin'
|
||||
import { usePhoneStore } from '@/stores/phone'
|
||||
import type { AdminCustomTone } from '@/types/admin'
|
||||
import { SkyButton } from '@/ui'
|
||||
import { MAX_CUSTOM_TONE_BYTES, playCustomPhoneTone } from '@/utils/customTones'
|
||||
|
||||
const MAX_DURATION_MS = 30_000
|
||||
const AUDIO_METADATA_TIMEOUT_MS = 8_000
|
||||
const props = defineProps<{ disabled?: boolean }>()
|
||||
const emit = defineEmits<{
|
||||
toast: [message: string, tone: 'error' | 'success']
|
||||
}>()
|
||||
const admin = useAdminStore()
|
||||
const phone = usePhoneStore()
|
||||
const fileInput = ref<HTMLInputElement | null>(null)
|
||||
const fileInputResetKey = ref(0)
|
||||
const label = ref('')
|
||||
const toneType = ref<'notification' | 'ringtone'>('ringtone')
|
||||
const selectedFile = ref<File | null>(null)
|
||||
const selectedMimeType = ref('')
|
||||
const selectedDurationMs = ref(0)
|
||||
const previewUrl = ref('')
|
||||
const processingFile = ref(false)
|
||||
const playingToneId = ref('')
|
||||
const pendingDeleteId = ref('')
|
||||
let stopPreview: (() => void) | null = null
|
||||
let previewTimer: number | undefined
|
||||
|
||||
const ringtoneTones = computed(() =>
|
||||
admin.customTones.filter((tone) => tone.toneType === 'ringtone'),
|
||||
)
|
||||
const notificationTones = computed(() =>
|
||||
admin.customTones.filter((tone) => tone.toneType === 'notification'),
|
||||
)
|
||||
const saving = computed(() => admin.actionKey === 'custom-tone:create')
|
||||
const canSave = computed(
|
||||
() =>
|
||||
!props.disabled &&
|
||||
!processingFile.value &&
|
||||
!saving.value &&
|
||||
label.value.trim().length >= 1 &&
|
||||
label.value.trim().length <= 64 &&
|
||||
!!selectedFile.value &&
|
||||
!!selectedMimeType.value &&
|
||||
selectedDurationMs.value >= 250 &&
|
||||
selectedDurationMs.value <= MAX_DURATION_MS,
|
||||
)
|
||||
|
||||
function t(key: string, params?: Record<string, string>): string {
|
||||
return phone.t(`AdminPanel.configurator.customTones.${key}`, params)
|
||||
}
|
||||
|
||||
function errorText(error?: string): string {
|
||||
const key = error || admin.error || 'request_failed'
|
||||
const translated = phone.t(`AdminPanel.errors.${key}`)
|
||||
return translated === `AdminPanel.errors.${key}`
|
||||
? phone.t('AdminPanel.errors.default')
|
||||
: translated
|
||||
}
|
||||
|
||||
function normalizedMimeType(file: File): string {
|
||||
const aliases: Record<string, string> = {
|
||||
'audio/mp3': 'audio/mpeg',
|
||||
'audio/x-wav': 'audio/wav',
|
||||
}
|
||||
const browserType = file.type.toLocaleLowerCase().split(';')[0]
|
||||
if (aliases[browserType]) return aliases[browserType]
|
||||
if (
|
||||
['audio/mpeg', 'audio/ogg', 'audio/wav', 'audio/webm'].includes(browserType)
|
||||
) {
|
||||
return browserType
|
||||
}
|
||||
const extension = file.name.split('.').pop()?.toLocaleLowerCase()
|
||||
return (
|
||||
{
|
||||
mp3: 'audio/mpeg',
|
||||
ogg: 'audio/ogg',
|
||||
wav: 'audio/wav',
|
||||
webm: 'audio/webm',
|
||||
}[extension ?? ''] ?? ''
|
||||
)
|
||||
}
|
||||
|
||||
function audioDuration(url: string): Promise<number> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const audio = new Audio(url)
|
||||
audio.preload = 'metadata'
|
||||
let settled = false
|
||||
const finish = (duration?: number): void => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
window.clearTimeout(timeout)
|
||||
audio.removeEventListener('loadedmetadata', handleMetadata)
|
||||
audio.removeEventListener('error', handleError)
|
||||
audio.removeAttribute('src')
|
||||
audio.load()
|
||||
if (duration !== undefined && Number.isFinite(duration)) {
|
||||
resolve(Math.round(duration * 1000))
|
||||
} else {
|
||||
reject(new Error('invalid_audio'))
|
||||
}
|
||||
}
|
||||
const handleMetadata = (): void => finish(audio.duration)
|
||||
const handleError = (): void => finish()
|
||||
const timeout = window.setTimeout(finish, AUDIO_METADATA_TIMEOUT_MS)
|
||||
audio.addEventListener('loadedmetadata', handleMetadata, { once: true })
|
||||
audio.addEventListener('error', handleError, { once: true })
|
||||
})
|
||||
}
|
||||
|
||||
function resetFileInput(target = fileInput.value): void {
|
||||
if (target) target.value = ''
|
||||
fileInputResetKey.value += 1
|
||||
}
|
||||
|
||||
function releaseSelectedFile(resetInput = true): void {
|
||||
if (previewUrl.value) URL.revokeObjectURL(previewUrl.value)
|
||||
previewUrl.value = ''
|
||||
selectedFile.value = null
|
||||
selectedMimeType.value = ''
|
||||
selectedDurationMs.value = 0
|
||||
if (resetInput) resetFileInput()
|
||||
}
|
||||
|
||||
async function chooseFile(event: Event): Promise<void> {
|
||||
const target = event.target
|
||||
if (!(target instanceof HTMLInputElement)) return
|
||||
const file = target.files?.[0]
|
||||
releaseSelectedFile(false)
|
||||
resetFileInput(target)
|
||||
if (!file) return
|
||||
const mimeType = normalizedMimeType(file)
|
||||
if (!mimeType) {
|
||||
emit('toast', t('errors.type'), 'error')
|
||||
return
|
||||
}
|
||||
if (file.size < 1 || file.size > MAX_CUSTOM_TONE_BYTES) {
|
||||
emit('toast', t('errors.size'), 'error')
|
||||
return
|
||||
}
|
||||
|
||||
processingFile.value = true
|
||||
const url = URL.createObjectURL(file)
|
||||
try {
|
||||
const durationMs = await audioDuration(url)
|
||||
if (durationMs < 250 || durationMs > MAX_DURATION_MS) {
|
||||
URL.revokeObjectURL(url)
|
||||
emit('toast', t('errors.duration'), 'error')
|
||||
return
|
||||
}
|
||||
selectedFile.value = file
|
||||
selectedMimeType.value = mimeType
|
||||
selectedDurationMs.value = durationMs
|
||||
previewUrl.value = url
|
||||
if (!label.value.trim()) {
|
||||
label.value = file.name.replace(/\.[^.]+$/, '').slice(0, 64)
|
||||
}
|
||||
} catch (error) {
|
||||
URL.revokeObjectURL(url)
|
||||
console.error('[Phone admin] Could not read the selected tone.', error)
|
||||
emit('toast', t('errors.invalid'), 'error')
|
||||
} finally {
|
||||
processingFile.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function fileBase64(file: File): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
reader.addEventListener('load', () => {
|
||||
if (typeof reader.result !== 'string') {
|
||||
reject(new Error('invalid_audio'))
|
||||
return
|
||||
}
|
||||
const delimiter = reader.result.indexOf(',')
|
||||
if (delimiter < 0) {
|
||||
reject(new Error('invalid_audio'))
|
||||
return
|
||||
}
|
||||
resolve(reader.result.slice(delimiter + 1))
|
||||
})
|
||||
reader.addEventListener('error', () => reject(reader.error))
|
||||
reader.readAsDataURL(file)
|
||||
})
|
||||
}
|
||||
|
||||
function stopActivePreview(): void {
|
||||
stopPreview?.()
|
||||
stopPreview = null
|
||||
playingToneId.value = ''
|
||||
if (previewTimer !== undefined) window.clearTimeout(previewTimer)
|
||||
previewTimer = undefined
|
||||
}
|
||||
|
||||
function previewSelected(): void {
|
||||
if (!previewUrl.value) return
|
||||
if (playingToneId.value === 'selected') {
|
||||
stopActivePreview()
|
||||
return
|
||||
}
|
||||
stopActivePreview()
|
||||
const player = new Audio(previewUrl.value)
|
||||
player.volume = 0.75
|
||||
player.addEventListener('ended', stopActivePreview, { once: true })
|
||||
void player.play().catch((error: unknown) => {
|
||||
console.error('[Phone admin] Could not preview selected tone.', error)
|
||||
emit('toast', t('errors.playback'), 'error')
|
||||
})
|
||||
playingToneId.value = 'selected'
|
||||
stopPreview = () => {
|
||||
player.pause()
|
||||
player.currentTime = 0
|
||||
}
|
||||
}
|
||||
|
||||
function previewStored(tone: AdminCustomTone): void {
|
||||
if (playingToneId.value === tone.id) {
|
||||
stopActivePreview()
|
||||
return
|
||||
}
|
||||
stopActivePreview()
|
||||
playingToneId.value = tone.id
|
||||
stopPreview = playCustomPhoneTone(
|
||||
{
|
||||
...tone,
|
||||
id: `custom:${tone.id}`,
|
||||
},
|
||||
75,
|
||||
false,
|
||||
{
|
||||
onError: () => {
|
||||
if (playingToneId.value !== tone.id) return
|
||||
stopActivePreview()
|
||||
emit('toast', t('errors.playback'), 'error')
|
||||
},
|
||||
onStarted: () => {
|
||||
if (playingToneId.value !== tone.id) return
|
||||
if (previewTimer !== undefined) window.clearTimeout(previewTimer)
|
||||
previewTimer = window.setTimeout(
|
||||
stopActivePreview,
|
||||
Math.min(MAX_DURATION_MS, tone.durationMs) + 750,
|
||||
)
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
async function saveTone(): Promise<void> {
|
||||
const file = selectedFile.value
|
||||
const name = label.value.trim()
|
||||
if (!canSave.value || !file) return
|
||||
try {
|
||||
const payload = await fileBase64(file)
|
||||
const response = await admin.createCustomTone({
|
||||
durationMs: selectedDurationMs.value,
|
||||
label: name,
|
||||
mimeType: selectedMimeType.value,
|
||||
payload,
|
||||
toneType: toneType.value,
|
||||
})
|
||||
if (!response.success) {
|
||||
emit('toast', errorText(response.error), 'error')
|
||||
return
|
||||
}
|
||||
label.value = ''
|
||||
releaseSelectedFile()
|
||||
emit('toast', t('saved'), 'success')
|
||||
} catch (error) {
|
||||
console.error('[Phone admin] Could not prepare the custom tone.', error)
|
||||
emit('toast', t('errors.invalid'), 'error')
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteTone(tone: AdminCustomTone): Promise<void> {
|
||||
if (pendingDeleteId.value !== tone.id) {
|
||||
pendingDeleteId.value = tone.id
|
||||
return
|
||||
}
|
||||
stopActivePreview()
|
||||
const response = await admin.deleteCustomTone(tone.id)
|
||||
pendingDeleteId.value = ''
|
||||
if (!response.success) {
|
||||
emit('toast', errorText(response.error), 'error')
|
||||
return
|
||||
}
|
||||
emit('toast', t('deleted'), 'success')
|
||||
}
|
||||
|
||||
function formatDuration(durationMs: number): string {
|
||||
return `${(durationMs / 1000).toLocaleString(phone.lang, {
|
||||
maximumFractionDigits: 1,
|
||||
})} s`
|
||||
}
|
||||
|
||||
function formatBytes(byteSize: number): string {
|
||||
return `${Math.ceil(byteSize / 1024).toLocaleString(phone.lang)} KB`
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
stopActivePreview()
|
||||
releaseSelectedFile(false)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="admin-custom-tones">
|
||||
<article class="admin-custom-tones__config-note">
|
||||
<FileCode2 :size="18" />
|
||||
<div>
|
||||
<strong>{{ t('configTitle') }}</strong>
|
||||
<p>{{ t('configBody') }}</p>
|
||||
<code>Config.CustomTones · config/custom_tones/</code>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<div class="admin-custom-tones__form">
|
||||
<label>
|
||||
<span>{{ t('name') }}</span>
|
||||
<input
|
||||
v-model="label"
|
||||
type="text"
|
||||
maxlength="64"
|
||||
autocomplete="off"
|
||||
:disabled="disabled || saving"
|
||||
:placeholder="t('namePlaceholder')"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<fieldset :disabled="disabled || saving">
|
||||
<legend>{{ t('category') }}</legend>
|
||||
<button
|
||||
type="button"
|
||||
:class="{ 'is-active': toneType === 'ringtone' }"
|
||||
@click="toneType = 'ringtone'"
|
||||
>
|
||||
{{ t('ringtone') }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
:class="{ 'is-active': toneType === 'notification' }"
|
||||
@click="toneType = 'notification'"
|
||||
>
|
||||
{{ t('notification') }}
|
||||
</button>
|
||||
</fieldset>
|
||||
|
||||
<label
|
||||
class="admin-custom-tones__picker"
|
||||
:class="{
|
||||
'is-disabled': disabled || saving || processingFile,
|
||||
'has-file': selectedFile,
|
||||
}"
|
||||
>
|
||||
<input
|
||||
:key="fileInputResetKey"
|
||||
ref="fileInput"
|
||||
class="admin-custom-tones__file-input"
|
||||
type="file"
|
||||
accept=".mp3,.ogg,.wav,.webm,audio/mpeg,audio/ogg,audio/wav,audio/webm"
|
||||
:disabled="disabled || saving || processingFile"
|
||||
@change="chooseFile"
|
||||
/>
|
||||
<LoaderCircle v-if="processingFile" :size="20" class="is-spinning" />
|
||||
<Upload v-else :size="20" />
|
||||
<span>
|
||||
<strong>{{ selectedFile?.name ?? t('chooseFile') }}</strong>
|
||||
<small>
|
||||
{{
|
||||
selectedFile
|
||||
? `${formatDuration(selectedDurationMs)} · ${formatBytes(selectedFile.size)}`
|
||||
: t('fileHint')
|
||||
}}
|
||||
</small>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<div class="admin-custom-tones__actions">
|
||||
<SkyButton
|
||||
small
|
||||
variant="secondary"
|
||||
:disabled="!previewUrl || saving"
|
||||
@click="previewSelected"
|
||||
>
|
||||
<Pause v-if="playingToneId === 'selected'" :size="17" />
|
||||
<Play v-else :size="17" />
|
||||
{{ t('preview') }}
|
||||
</SkyButton>
|
||||
<SkyButton small :disabled="!canSave" @click="saveTone">
|
||||
<LoaderCircle v-if="saving" :size="17" class="is-spinning" />
|
||||
<Plus v-else :size="17" />
|
||||
{{ t('add') }}
|
||||
</SkyButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="admin.customTonesLoading" class="admin-custom-tones__loading">
|
||||
<LoaderCircle :size="22" class="is-spinning" />
|
||||
{{ t('loading') }}
|
||||
</div>
|
||||
<div v-else class="admin-custom-tones__catalog">
|
||||
<section
|
||||
v-for="group in [
|
||||
{ key: 'ringtone', label: t('ringtones'), tones: ringtoneTones },
|
||||
{
|
||||
key: 'notification',
|
||||
label: t('notifications'),
|
||||
tones: notificationTones,
|
||||
},
|
||||
]"
|
||||
:key="group.key"
|
||||
>
|
||||
<header>
|
||||
<strong>{{ group.label }}</strong>
|
||||
<span>{{ group.tones.length }}/32</span>
|
||||
</header>
|
||||
<p v-if="!group.tones.length" class="admin-custom-tones__empty">
|
||||
{{ t('empty') }}
|
||||
</p>
|
||||
<article v-for="tone in group.tones" :key="tone.id">
|
||||
<span class="admin-custom-tones__tone-icon"
|
||||
><FileAudio :size="18"
|
||||
/></span>
|
||||
<span class="admin-custom-tones__tone-copy">
|
||||
<strong>{{ tone.label }}</strong>
|
||||
<small>
|
||||
{{ formatDuration(tone.durationMs) }} ·
|
||||
{{ formatBytes(tone.byteSize) }} ·
|
||||
{{
|
||||
tone.source === 'config' ? t('configSource') : tone.createdBy
|
||||
}}
|
||||
</small>
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
:aria-label="t('preview')"
|
||||
@click="previewStored(tone)"
|
||||
>
|
||||
<Pause v-if="playingToneId === tone.id" :size="17" />
|
||||
<Play v-else :size="17" />
|
||||
</button>
|
||||
<button
|
||||
v-if="tone.source === 'database'"
|
||||
type="button"
|
||||
class="is-danger"
|
||||
:class="{ 'is-confirming': pendingDeleteId === tone.id }"
|
||||
:aria-label="
|
||||
pendingDeleteId === tone.id ? t('confirmDelete') : t('delete')
|
||||
"
|
||||
:disabled="admin.actionKey === `custom-tone:delete:${tone.id}`"
|
||||
@click="deleteTone(tone)"
|
||||
>
|
||||
<LoaderCircle
|
||||
v-if="admin.actionKey === `custom-tone:delete:${tone.id}`"
|
||||
:size="17"
|
||||
class="is-spinning"
|
||||
/>
|
||||
<Trash2 v-else :size="17" />
|
||||
</button>
|
||||
<span
|
||||
v-else
|
||||
class="admin-custom-tones__locked"
|
||||
:title="t('configManaged')"
|
||||
:aria-label="t('configManaged')"
|
||||
>
|
||||
<LockKeyhole :size="15" />
|
||||
</span>
|
||||
</article>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.admin-custom-tones {
|
||||
--sky-app-accent: var(--admin-green, #5ccb70);
|
||||
--sky-button-text: #fff;
|
||||
--sky-surface-muted: #1b1e1b;
|
||||
--sky-text: #fff;
|
||||
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
color: var(--admin-text, #f0f3f0);
|
||||
}
|
||||
|
||||
.admin-custom-tones__config-note {
|
||||
display: grid;
|
||||
grid-template-columns: 26px minmax(0, 1fr);
|
||||
align-items: start;
|
||||
gap: 9px;
|
||||
padding: 10px 11px;
|
||||
border-radius: 3px;
|
||||
background: linear-gradient(90deg, rgb(0 184 228 / 11%), transparent 82%);
|
||||
}
|
||||
|
||||
.admin-custom-tones__config-note > svg {
|
||||
color: var(--admin-accent, #00b8e4);
|
||||
}
|
||||
|
||||
.admin-custom-tones__config-note div {
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.admin-custom-tones__config-note strong {
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.admin-custom-tones__config-note p {
|
||||
margin: 0;
|
||||
color: var(--admin-muted, #818781);
|
||||
font-size: 9px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.admin-custom-tones__config-note code {
|
||||
width: fit-content;
|
||||
margin-top: 2px;
|
||||
padding: 3px 5px;
|
||||
border-radius: 3px;
|
||||
color: #8edcf0;
|
||||
background: rgb(0 0 0 / 25%);
|
||||
font-size: 8px;
|
||||
}
|
||||
|
||||
.admin-custom-tones__tone-icon {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
flex: 0 0 40px;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 10px;
|
||||
background: rgb(0 194 255 / 10%);
|
||||
color: #14c9ff;
|
||||
}
|
||||
|
||||
.admin-custom-tones__empty {
|
||||
margin: 4px 0 0;
|
||||
color: #8f9994;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.admin-custom-tones__form {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(220px, 1fr) minmax(230px, 0.8fr);
|
||||
gap: 8px;
|
||||
padding: 10px;
|
||||
border: 1px solid var(--admin-border, rgb(255 255 255 / 5%));
|
||||
border-radius: 3px;
|
||||
background: var(--admin-panel-raised, #131514);
|
||||
}
|
||||
|
||||
.admin-custom-tones__form label,
|
||||
.admin-custom-tones__form fieldset {
|
||||
display: grid;
|
||||
gap: 7px;
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.admin-custom-tones__form label > span,
|
||||
.admin-custom-tones__form legend {
|
||||
color: #aab3af;
|
||||
font-size: 8px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.admin-custom-tones__form input[type='text'] {
|
||||
min-height: 36px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid var(--admin-border-strong, rgb(255 255 255 / 9%));
|
||||
border-radius: 4px;
|
||||
outline: none;
|
||||
background: #1b1e1b;
|
||||
color: #fff;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.admin-custom-tones__form input[type='text']:focus {
|
||||
border-color: #14c9ff;
|
||||
box-shadow: 0 0 0 2px rgb(20 201 255 / 14%);
|
||||
}
|
||||
|
||||
.admin-custom-tones__form fieldset {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
|
||||
.admin-custom-tones__form legend {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.admin-custom-tones__form fieldset button {
|
||||
min-height: 36px;
|
||||
border: 1px solid var(--admin-border-strong, rgb(255 255 255 / 9%));
|
||||
border-radius: 4px;
|
||||
background: #1b1e1b;
|
||||
color: #b7c0bc;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.admin-custom-tones__form fieldset button.is-active {
|
||||
border-color: #14c9ff;
|
||||
background: rgb(20 201 255 / 12%);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.admin-custom-tones__file-input {
|
||||
position: absolute;
|
||||
z-index: 3;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
opacity: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.admin-custom-tones__form .admin-custom-tones__picker {
|
||||
position: relative;
|
||||
display: flex;
|
||||
grid-column: 1 / -1;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
min-height: 52px;
|
||||
padding: 8px 11px;
|
||||
border: 1px dashed var(--admin-border-strong, rgb(255 255 255 / 9%));
|
||||
border-radius: 4px;
|
||||
background: #111311;
|
||||
color: #dfe7e3;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.admin-custom-tones__picker:focus-within,
|
||||
.admin-custom-tones__picker:hover:not(.is-disabled) {
|
||||
border-color: var(--admin-accent, #00b8e4);
|
||||
background: rgb(0 184 228 / 6%);
|
||||
}
|
||||
|
||||
.admin-custom-tones__picker.is-disabled {
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.admin-custom-tones__picker > span,
|
||||
.admin-custom-tones__tone-copy {
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.admin-custom-tones__picker strong,
|
||||
.admin-custom-tones__tone-copy strong {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.admin-custom-tones__picker small,
|
||||
.admin-custom-tones__tone-copy small {
|
||||
color: #89938e;
|
||||
font-size: 8px;
|
||||
}
|
||||
|
||||
.admin-custom-tones__actions {
|
||||
display: flex;
|
||||
grid-column: 1 / -1;
|
||||
gap: 10px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.admin-custom-tones__actions :deep(.sky-button) {
|
||||
flex: 1 1 0;
|
||||
border-color: var(--admin-border-strong, rgb(255 255 255 / 9%));
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.admin-custom-tones__catalog {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.admin-custom-tones__catalog > section {
|
||||
overflow: hidden;
|
||||
border: 1px solid rgb(255 255 255 / 7%);
|
||||
border-radius: 3px;
|
||||
background: #111311;
|
||||
}
|
||||
|
||||
.admin-custom-tones__catalog > section > header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
min-height: 44px;
|
||||
padding: 0 10px;
|
||||
border-bottom: 1px solid rgb(255 255 255 / 7%);
|
||||
}
|
||||
|
||||
.admin-custom-tones__catalog > section > header span {
|
||||
color: #82908a;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.admin-custom-tones__catalog > section > header strong {
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.admin-custom-tones__catalog article {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
min-height: 54px;
|
||||
padding: 8px 10px;
|
||||
border-bottom: 1px solid rgb(255 255 255 / 5%);
|
||||
}
|
||||
|
||||
.admin-custom-tones__catalog article:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.admin-custom-tones__tone-icon {
|
||||
flex-basis: 30px;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.admin-custom-tones__tone-copy {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.admin-custom-tones__catalog article > button {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
flex: 0 0 32px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 1px solid var(--admin-border-strong, rgb(255 255 255 / 9%));
|
||||
border-radius: 4px;
|
||||
background: #151a18;
|
||||
color: #dfe7e3;
|
||||
}
|
||||
|
||||
.admin-custom-tones__locked {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
flex: 0 0 32px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
color: var(--admin-dim, #555b55);
|
||||
}
|
||||
|
||||
.admin-custom-tones__catalog article > button.is-danger {
|
||||
color: #ff6875;
|
||||
}
|
||||
|
||||
.admin-custom-tones__catalog article > button.is-confirming {
|
||||
border-color: #ff5263;
|
||||
background: rgb(255 82 99 / 14%);
|
||||
}
|
||||
|
||||
.admin-custom-tones__empty,
|
||||
.admin-custom-tones__loading {
|
||||
padding: 18px 13px;
|
||||
}
|
||||
|
||||
.admin-custom-tones__loading {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
color: #98a39e;
|
||||
}
|
||||
|
||||
.is-spinning {
|
||||
animation: admin-custom-tone-spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes admin-custom-tone-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.admin-custom-tones__form,
|
||||
.admin-custom-tones__catalog {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -144,6 +144,7 @@ describe('standalone admin panel contracts', () => {
|
||||
'messages',
|
||||
'calls',
|
||||
'moderation',
|
||||
'tones',
|
||||
'audit',
|
||||
'configurator',
|
||||
]) {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import {
|
||||
ChartNoAxesCombined,
|
||||
BadgeDollarSign,
|
||||
BellRing,
|
||||
BriefcaseBusiness,
|
||||
Check,
|
||||
ChevronRight,
|
||||
@@ -65,6 +66,7 @@ import { nuiCall } from '@/utils/nui'
|
||||
import AdminConfigValueEditor, {
|
||||
type AdminConfigEditorLabels,
|
||||
} from './AdminConfigValueEditor.vue'
|
||||
import AdminCustomToneManager from './AdminCustomToneManager.vue'
|
||||
|
||||
type AdminTab =
|
||||
| 'overview'
|
||||
@@ -75,6 +77,7 @@ type AdminTab =
|
||||
| 'messages'
|
||||
| 'calls'
|
||||
| 'moderation'
|
||||
| 'tones'
|
||||
| 'audit'
|
||||
| 'configurator'
|
||||
type ConfiguratorScope = 'config' | 'media'
|
||||
@@ -538,8 +541,15 @@ function selectTab(nextTab: AdminTab): void {
|
||||
if (nextTab === 'overview' && admin.initialized && !admin.loading) {
|
||||
void admin.load()
|
||||
}
|
||||
if (nextTab === 'configurator' && !admin.configurator) {
|
||||
void admin.loadConfigurator().then((loaded) => {
|
||||
if (nextTab === 'configurator') {
|
||||
void (
|
||||
admin.configurator ? Promise.resolve(true) : admin.loadConfigurator()
|
||||
).then((loaded) => {
|
||||
if (!loaded) showToast(errorText(), 'error')
|
||||
})
|
||||
}
|
||||
if (nextTab === 'tones') {
|
||||
void admin.loadCustomTones().then((loaded) => {
|
||||
if (!loaded) showToast(errorText(), 'error')
|
||||
})
|
||||
}
|
||||
@@ -863,7 +873,9 @@ onBeforeUnmount(() => {
|
||||
<strong>{{
|
||||
tab === 'configurator'
|
||||
? t('configurator.context')
|
||||
: admin.selectedPlayer?.name || t('editor.noSelection')
|
||||
: tab === 'tones'
|
||||
? t('configurator.customTones.context')
|
||||
: admin.selectedPlayer?.name || t('editor.noSelection')
|
||||
}}</strong>
|
||||
</div>
|
||||
|
||||
@@ -969,6 +981,15 @@ onBeforeUnmount(() => {
|
||||
>
|
||||
<ShieldAlert :size="19" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
:class="{ 'is-active': tab === 'tones' }"
|
||||
:aria-label="t('tabs.tones')"
|
||||
:title="t('tabs.tones')"
|
||||
@click="selectTab('tones')"
|
||||
>
|
||||
<BellRing :size="19" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
:class="{ 'is-active': tab === 'audit' }"
|
||||
@@ -1064,6 +1085,14 @@ onBeforeUnmount(() => {
|
||||
</span>
|
||||
<ChevronRight :size="14" />
|
||||
</button>
|
||||
<button type="button" @click="selectTab('tones')">
|
||||
<BellRing :size="17" />
|
||||
<span>
|
||||
<strong>{{ t('tabs.tones') }}</strong>
|
||||
<small>{{ t('overview.tonesFeature') }}</small>
|
||||
</span>
|
||||
<ChevronRight :size="14" />
|
||||
</button>
|
||||
<button type="button" @click="selectTab('configurator')">
|
||||
<Settings2 :size="17" />
|
||||
<span>
|
||||
@@ -1075,6 +1104,46 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else-if="tab === 'tones'">
|
||||
<div class="admin-panel-directory__header">
|
||||
<div>
|
||||
<span>{{ t('configurator.customTones.eyebrow') }}</span>
|
||||
<h2>{{ t('configurator.customTones.library') }}</h2>
|
||||
</div>
|
||||
<strong>{{ admin.customTones.length }}</strong>
|
||||
</div>
|
||||
<div class="admin-panel-tone-directory">
|
||||
<article>
|
||||
<span><PhoneCall :size="16" /></span>
|
||||
<div>
|
||||
<strong>{{ t('configurator.customTones.ringtones') }}</strong>
|
||||
<small
|
||||
>{{
|
||||
admin.customTones.filter(
|
||||
(tone) => tone.toneType === 'ringtone',
|
||||
).length
|
||||
}}/32</small
|
||||
>
|
||||
</div>
|
||||
</article>
|
||||
<article>
|
||||
<span><MessageSquare :size="16" /></span>
|
||||
<div>
|
||||
<strong>{{
|
||||
t('configurator.customTones.notifications')
|
||||
}}</strong>
|
||||
<small
|
||||
>{{
|
||||
admin.customTones.filter(
|
||||
(tone) => tone.toneType === 'notification',
|
||||
).length
|
||||
}}/32</small
|
||||
>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else-if="tab === 'configurator'">
|
||||
<div class="admin-panel-directory__header">
|
||||
<div>
|
||||
@@ -1369,6 +1438,23 @@ onBeforeUnmount(() => {
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section
|
||||
v-else-if="tab === 'tones'"
|
||||
class="admin-panel-editor__scroll"
|
||||
>
|
||||
<div class="admin-panel-page-heading">
|
||||
<div class="admin-panel-heading-icon">
|
||||
<BellRing :size="23" />
|
||||
</div>
|
||||
<div>
|
||||
<span>{{ t('configurator.customTones.eyebrow') }}</span>
|
||||
<h1>{{ t('configurator.customTones.title') }}</h1>
|
||||
<p>{{ t('configurator.customTones.body') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<AdminCustomToneManager @toast="showToast" />
|
||||
</section>
|
||||
|
||||
<section
|
||||
v-else-if="tab === 'configurator'"
|
||||
class="admin-panel-editor__scroll"
|
||||
@@ -2926,6 +3012,47 @@ button:disabled {
|
||||
background: var(--admin-row-hover);
|
||||
}
|
||||
|
||||
.admin-panel-tone-directory {
|
||||
display: grid;
|
||||
gap: 1px;
|
||||
margin-top: 8px;
|
||||
background: var(--admin-border);
|
||||
}
|
||||
|
||||
.admin-panel-tone-directory article {
|
||||
min-height: 52px;
|
||||
display: grid;
|
||||
grid-template-columns: 30px minmax(0, 1fr);
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
padding: 8px 10px;
|
||||
background: #111311;
|
||||
}
|
||||
|
||||
.admin-panel-tone-directory article > span {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 5px;
|
||||
color: var(--admin-accent);
|
||||
background: var(--admin-green-soft);
|
||||
}
|
||||
|
||||
.admin-panel-tone-directory article > div {
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.admin-panel-tone-directory strong {
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.admin-panel-tone-directory small {
|
||||
color: var(--admin-muted);
|
||||
font-size: 9px;
|
||||
}
|
||||
|
||||
.admin-panel-audit-icon {
|
||||
width: 27px;
|
||||
height: 27px;
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const resource = (path: string) =>
|
||||
readFileSync(new URL(`../../sky_phone/${path}`, import.meta.url), 'utf8')
|
||||
const customTonesServer = resource('source/server/custom_tones.lua')
|
||||
const databaseMigration = resource('source/server/db_migrate.lua')
|
||||
const clientMain = resource('source/client/main.lua')
|
||||
const clientTones = resource('source/client/custom_tones.lua')
|
||||
const manifest = resource('fxmanifest.lua')
|
||||
const nuiServerBridge = resource('source/client/nui_server_bridge.lua')
|
||||
const settingsSource = readFileSync(
|
||||
new URL('./views/apps/SettingsApp.vue', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const adminManager = readFileSync(
|
||||
new URL('./components/AdminCustomToneManager.vue', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const adminStore = readFileSync(
|
||||
new URL('./stores/admin.ts', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const adminPanel = readFileSync(
|
||||
new URL('./components/AdminPanel.vue', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const config = resource('config/config.lua')
|
||||
const configurator = resource('source/server/phone_configurator.lua')
|
||||
|
||||
describe('custom phone tone contract', () => {
|
||||
it('stores bounded audio payloads in a phone-owned table', () => {
|
||||
expect(databaseMigration).toContain('name = "sky_phone_custom_tones"')
|
||||
expect(databaseMigration).toContain(
|
||||
'{ name = "audio_payload", type = "MEDIUMTEXT NOT NULL"',
|
||||
)
|
||||
expect(customTonesServer).toContain('local MAX_TONES_PER_TYPE = 32')
|
||||
expect(customTonesServer).toContain('local MAX_AUDIO_BYTES = 2000000')
|
||||
expect(customTonesServer).toContain('local MAX_TRANSFER_CHUNK_CHARS = 8000')
|
||||
expect(customTonesServer).toContain('local MAX_TRANSFER_CHUNKS = 334')
|
||||
expect(customTonesServer).toContain('local MAX_DURATION_MS = 30000')
|
||||
expect(customTonesServer).toContain('decoded_base64_size')
|
||||
expect(customTonesServer).toContain('valid_audio_signature')
|
||||
})
|
||||
|
||||
it('keeps creation and deletion behind server-side admin authorization', () => {
|
||||
const adminServer = resource('source/server/admin.lua')
|
||||
expect(adminServer).toMatch(
|
||||
/^Bridge\.Database\.AfterMigration\("sky_phone", function\(\)/,
|
||||
)
|
||||
expect(adminServer.trimEnd()).toMatch(/end\)\r?\nend\)$/)
|
||||
expect(adminServer).toContain('"sky_phone:admin:tone-upload-start"')
|
||||
expect(adminServer).toContain('"sky_phone:admin:tone-upload-chunk"')
|
||||
expect(adminServer).toContain(
|
||||
'math.max(400, tonumber(Config.AdminPanel.ActionRequestsPerMinute) or 0)',
|
||||
)
|
||||
expect(adminServer).toContain('"sky_phone:admin:tone-upload-finish"')
|
||||
expect(adminServer).toContain('"sky_phone:admin:delete-tone"')
|
||||
expect(adminServer).toContain('require_admin(')
|
||||
expect(adminServer).toContain('"create_custom_tone"')
|
||||
expect(adminServer).toContain('"delete_custom_tone"')
|
||||
})
|
||||
|
||||
it('loads metadata on readiness and audio only when it is played', () => {
|
||||
expect(clientMain).toContain(
|
||||
'Bridge.Callbacks.Trigger("sky_phone:tones:list"',
|
||||
)
|
||||
expect(clientMain).toContain('type = "phone:tones"')
|
||||
expect(customTonesServer).toContain('TriggerLatentClientEvent(')
|
||||
expect(customTonesServer).toContain('"sky_phone:tones:audio-request"')
|
||||
expect(customTonesServer).toContain('AUDIO_TRANSFER_BYTES_PER_SECOND')
|
||||
expect(clientTones).toContain('RegisterNUICallback("tones:audio"')
|
||||
expect(clientTones).toContain('"sky_phone:tones:audio-response"')
|
||||
expect(clientTones).toContain('AUDIO_TRANSFER_TIMEOUT_MS')
|
||||
expect(manifest).toContain("'source/client/custom_tones.lua'")
|
||||
expect(nuiServerBridge).not.toContain('audio-start')
|
||||
expect(nuiServerBridge).not.toContain('audio-chunk')
|
||||
expect(customTonesServer).toContain(
|
||||
'SELECT `id`, `mime_type`, `audio_payload`',
|
||||
)
|
||||
})
|
||||
|
||||
it('offers direct local-file management without a URL field', () => {
|
||||
expect(adminManager).toContain('type="file"')
|
||||
expect(adminManager).toContain('readAsDataURL(file)')
|
||||
expect(adminManager).not.toContain('type="url"')
|
||||
expect(adminManager).not.toContain('https://')
|
||||
expect(adminManager).not.toContain('fileInput.value?.click()')
|
||||
expect(adminManager).toContain(':key="fileInputResetKey"')
|
||||
expect(adminManager).toContain('AUDIO_METADATA_TIMEOUT_MS')
|
||||
expect(adminManager).toContain('onStarted: () =>')
|
||||
expect(adminManager).toContain("t('errors.playback')")
|
||||
expect(adminStore).toContain('finally {')
|
||||
expect(adminStore).toContain("this.actionKey = ''")
|
||||
expect(adminStore).toContain('cacheCustomPhoneTonePayload(')
|
||||
expect(adminPanel).toContain("| 'tones'")
|
||||
expect(adminPanel).toContain("selectTab('tones')")
|
||||
})
|
||||
|
||||
it('supports URL-free file configuration outside the phone panel', () => {
|
||||
expect(config).toContain('Config.CustomTones = {')
|
||||
expect(config).toContain('config/custom_tones/')
|
||||
expect(customTonesServer).toContain('LoadResourceFile(')
|
||||
expect(configurator).toContain('CustomTones = true')
|
||||
expect(configurator).toContain('and key ~= "CustomTones"')
|
||||
})
|
||||
|
||||
it('shows built-in and custom choices in both sound categories', () => {
|
||||
expect(settingsSource).toContain('phone.customTones.ringtones.map')
|
||||
expect(settingsSource).toContain('phone.customTones.notificationSounds.map')
|
||||
expect(settingsSource).toContain('v-for="ringtone in ringtoneChoices"')
|
||||
expect(settingsSource).toContain(
|
||||
'v-for="sound in notificationSoundChoices"',
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -30,13 +30,17 @@ describe('fixed server permissions', () => {
|
||||
expect(config).not.toContain('AdminGroups =')
|
||||
expect(configDefault).not.toContain('Config.PhoneConfigurator')
|
||||
expect(configDefault).not.toContain('Config.CommandPermissions')
|
||||
expect(configDefault).not.toContain('Config.CustomTones')
|
||||
expect(configDefault).not.toContain('AdminGroups =')
|
||||
})
|
||||
|
||||
it('keeps fixed permissions outside SQL and removes legacy group fields', () => {
|
||||
expect(configurator).toContain('key ~= "CommandPermissions"')
|
||||
expect(configurator).toContain('if key ~= "CommandPermissions" then')
|
||||
expect(configurator).toContain(
|
||||
'if key ~= "CommandPermissions" and key ~= "CustomTones" then',
|
||||
)
|
||||
expect(configuratorFixture).toContain('delete config.CommandPermissions')
|
||||
expect(configuratorFixture).toContain('delete config.CustomTones')
|
||||
for (const path of [
|
||||
'AdminPanel.AdminGroups',
|
||||
'TestData.AdminGroups',
|
||||
@@ -55,7 +59,7 @@ describe('fixed server permissions', () => {
|
||||
'^1 Configure all phone and media settings IN GAME through /phonepanel.^0',
|
||||
)
|
||||
expect(configurator).toContain(
|
||||
'^1 Only Config.PhoneConfigurator.Enabled and Config.CommandPermissions remain file-based.^0',
|
||||
'^1 Config.PhoneConfigurator, Config.CommandPermissions and Config.CustomTones remain file-based.^0',
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -8,11 +8,17 @@ import type {
|
||||
AdminConfigurator,
|
||||
AdminConfiguratorChange,
|
||||
AdminCredential,
|
||||
AdminCustomTone,
|
||||
AdminCustomToneCreate,
|
||||
AdminMessageActivity,
|
||||
AdminPlayerDetail,
|
||||
AdminPlayerSummary,
|
||||
AdminStats,
|
||||
} from '@/types/admin'
|
||||
import {
|
||||
cacheCustomPhoneTonePayload,
|
||||
CUSTOM_TONE_CHUNK_CHARS,
|
||||
} from '@/utils/customTones'
|
||||
import { nuiCall, type NuiResponse } from '@/utils/nui'
|
||||
|
||||
const EMPTY_STATS: AdminStats = {
|
||||
@@ -49,6 +55,8 @@ export const useAdminStore = defineStore('admin', {
|
||||
audit: [] as AdminAuditEntry[],
|
||||
configurator: null as AdminConfigurator | null,
|
||||
configuratorLoading: false,
|
||||
customTones: [] as AdminCustomTone[],
|
||||
customTonesLoading: false,
|
||||
detailLoading: false,
|
||||
disabledApps: [] as string[],
|
||||
error: '',
|
||||
@@ -205,6 +213,99 @@ export const useAdminStore = defineStore('admin', {
|
||||
}
|
||||
return response
|
||||
},
|
||||
async loadCustomTones(): Promise<boolean> {
|
||||
this.customTonesLoading = true
|
||||
const response = await nuiCall<AdminCustomTone[]>('admin:tones')
|
||||
this.customTonesLoading = false
|
||||
if (!response.success || !response.data) {
|
||||
this.error = response.error ?? 'request_failed'
|
||||
return false
|
||||
}
|
||||
this.customTones = response.data
|
||||
this.error = ''
|
||||
return true
|
||||
},
|
||||
async createCustomTone(
|
||||
tone: AdminCustomToneCreate,
|
||||
): Promise<NuiResponse<AdminCustomTone[]>> {
|
||||
this.actionKey = 'custom-tone:create'
|
||||
const { payload, ...metadata } = tone
|
||||
let uploadId = ''
|
||||
try {
|
||||
const started = await nuiCall<{ uploadId: string }>(
|
||||
'admin:tone-upload-start',
|
||||
{
|
||||
...metadata,
|
||||
payloadLength: payload.length,
|
||||
},
|
||||
)
|
||||
uploadId = started.data?.uploadId ?? ''
|
||||
if (!started.success || !uploadId) {
|
||||
this.error = started.error ?? 'request_failed'
|
||||
return { error: this.error, success: false }
|
||||
}
|
||||
|
||||
for (let offset = 0, index = 1; offset < payload.length; index += 1) {
|
||||
const response = await nuiCall('admin:tone-upload-chunk', {
|
||||
chunk: payload.slice(offset, offset + CUSTOM_TONE_CHUNK_CHARS),
|
||||
index,
|
||||
uploadId,
|
||||
})
|
||||
if (!response.success) {
|
||||
await nuiCall('admin:tone-upload-cancel', { uploadId })
|
||||
this.error = response.error ?? 'request_failed'
|
||||
return { error: this.error, success: false }
|
||||
}
|
||||
offset += CUSTOM_TONE_CHUNK_CHARS
|
||||
}
|
||||
|
||||
const response: NuiResponse<AdminCustomTone[]> & {
|
||||
toneId?: unknown
|
||||
} = await nuiCall<AdminCustomTone[]>('admin:tone-upload-finish', {
|
||||
uploadId,
|
||||
})
|
||||
if (response.success && response.data) {
|
||||
this.customTones = response.data
|
||||
if (typeof response.toneId === 'string') {
|
||||
cacheCustomPhoneTonePayload(
|
||||
`custom:${response.toneId}`,
|
||||
tone.mimeType,
|
||||
payload,
|
||||
)
|
||||
}
|
||||
this.error = ''
|
||||
} else {
|
||||
await nuiCall('admin:tone-upload-cancel', { uploadId })
|
||||
this.error = response.error ?? 'request_failed'
|
||||
}
|
||||
return response
|
||||
} catch (error) {
|
||||
if (uploadId) {
|
||||
await nuiCall('admin:tone-upload-cancel', { uploadId })
|
||||
}
|
||||
console.error('[Phone admin] Custom tone upload failed.', error)
|
||||
this.error = 'request_failed'
|
||||
return { error: this.error, success: false }
|
||||
} finally {
|
||||
this.actionKey = ''
|
||||
}
|
||||
},
|
||||
async deleteCustomTone(
|
||||
id: string,
|
||||
): Promise<NuiResponse<AdminCustomTone[]>> {
|
||||
this.actionKey = `custom-tone:delete:${id}`
|
||||
const response = await nuiCall<AdminCustomTone[]>('admin:delete-tone', {
|
||||
id,
|
||||
})
|
||||
this.actionKey = ''
|
||||
if (response.success && response.data) {
|
||||
this.customTones = response.data
|
||||
this.error = ''
|
||||
} else {
|
||||
this.error = response.error ?? 'request_failed'
|
||||
}
|
||||
return response
|
||||
},
|
||||
async resetPasscode(
|
||||
source: number,
|
||||
imei: string,
|
||||
|
||||
@@ -4,14 +4,18 @@ import { ref } from 'vue'
|
||||
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 { findCustomPhoneTone, playCustomPhoneTone } from '@/utils/customTones'
|
||||
import {
|
||||
isBuiltInRingtoneId,
|
||||
type BuiltInRingtoneId,
|
||||
} from '@/utils/preferences'
|
||||
import {
|
||||
playPhoneTone,
|
||||
playPhoneVibration,
|
||||
type PhoneToneId,
|
||||
} from '@/utils/tones'
|
||||
|
||||
const RINGTONE_TONES: Record<RingtoneId, PhoneToneId> = {
|
||||
const RINGTONE_TONES: Record<BuiltInRingtoneId, PhoneToneId> = {
|
||||
horizon: 'aurora',
|
||||
pulse: 'signal',
|
||||
skyline: 'apex',
|
||||
@@ -24,6 +28,21 @@ export const useCallsStore = defineStore('calls', () => {
|
||||
const recents = ref<RecentCall[]>([])
|
||||
let stopRingtone: (() => void) | null = null
|
||||
|
||||
function playSelectedRingtone(volume: number): () => void {
|
||||
const selected = phone.preferences.settings.ringtone
|
||||
const customTone = findCustomPhoneTone(
|
||||
phone.customTones.ringtones,
|
||||
selected,
|
||||
)
|
||||
if (customTone) return playCustomPhoneTone(customTone, volume, true)
|
||||
|
||||
return playPhoneTone(
|
||||
isBuiltInRingtoneId(selected) ? RINGTONE_TONES[selected] : 'apex',
|
||||
volume,
|
||||
true,
|
||||
)
|
||||
}
|
||||
|
||||
async function bootstrap(): Promise<void> {
|
||||
await Promise.all([loadContacts(), loadRecents()])
|
||||
}
|
||||
@@ -173,11 +192,7 @@ export const useCallsStore = defineStore('calls', () => {
|
||||
phone.preferences.settings.ringtoneVolume === 0
|
||||
stopRingtone = alertsMuted
|
||||
? playPhoneVibration('call', true)
|
||||
: playPhoneTone(
|
||||
RINGTONE_TONES[phone.preferences.settings.ringtone],
|
||||
phone.preferences.settings.ringtoneVolume,
|
||||
true,
|
||||
)
|
||||
: playSelectedRingtone(phone.preferences.settings.ringtoneVolume)
|
||||
}
|
||||
if (!['ringing', 'connected'].includes(call.state)) {
|
||||
window.setTimeout(() => {
|
||||
|
||||
@@ -5,8 +5,10 @@ import { isPhoneAppId } from '@/config/apps'
|
||||
import { usePhoneStore } from '@/stores/phone'
|
||||
import type { LaunchablePhoneAppId } from '@/types/apps'
|
||||
import { nuiCall } from '@/utils/nui'
|
||||
import { findCustomPhoneTone, playCustomPhoneTone } from '@/utils/customTones'
|
||||
import {
|
||||
DEFAULT_APP_NOTIFICATION_PREFERENCES,
|
||||
isBuiltInNotificationSoundId,
|
||||
type PhonePreferencesV1,
|
||||
} from '@/utils/preferences'
|
||||
import {
|
||||
@@ -190,18 +192,34 @@ export const useNotificationsStore = defineStore('notifications', () => {
|
||||
const alertsMuted =
|
||||
preferences.settings.notificationVolume === 0 &&
|
||||
preferences.settings.ringtoneVolume === 0
|
||||
const sound = notification.sound ?? preferences.settings.notificationSound
|
||||
const selectedSound = preferences.settings.notificationSound
|
||||
const customSound = notification.sound
|
||||
? undefined
|
||||
: findCustomPhoneTone(
|
||||
phone.customTones.notificationSounds,
|
||||
selectedSound,
|
||||
)
|
||||
const volume = notification.critical
|
||||
? preferences.settings.ringtoneVolume
|
||||
: preferences.settings.notificationVolume
|
||||
stopToneHandles.set(
|
||||
notification.id,
|
||||
alertsMuted
|
||||
? playPhoneVibration(
|
||||
'notification',
|
||||
!!notification.persistent,
|
||||
)
|
||||
: playPhoneTone(sound, volume, !!notification.persistent),
|
||||
? playPhoneVibration('notification', !!notification.persistent)
|
||||
: customSound
|
||||
? playCustomPhoneTone(
|
||||
customSound,
|
||||
volume,
|
||||
!!notification.persistent,
|
||||
)
|
||||
: playPhoneTone(
|
||||
notification.sound ??
|
||||
(isBuiltInNotificationSoundId(selectedSound)
|
||||
? selectedSound
|
||||
: 'chime'),
|
||||
volume,
|
||||
!!notification.persistent,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,11 @@ import type {
|
||||
} from '@/types/device'
|
||||
import { clampPage } from '@/utils/pages'
|
||||
import { cloneJsonData } from '@/utils/clone'
|
||||
import {
|
||||
EMPTY_CUSTOM_PHONE_TONES,
|
||||
isCustomTonePreferenceId,
|
||||
parseCustomPhoneToneCatalog,
|
||||
} from '@/utils/customTones'
|
||||
import { nuiCall } from '@/utils/nui'
|
||||
import type { NuiResponse } from '@/utils/nui'
|
||||
import {
|
||||
@@ -823,6 +828,7 @@ const adminPanelFallbackLocales = {
|
||||
messages: 'Messages',
|
||||
calls: 'Calls',
|
||||
moderation: 'Moderation',
|
||||
tones: 'Sounds',
|
||||
audit: 'Audit',
|
||||
configurator: 'Phone configurator',
|
||||
},
|
||||
@@ -846,6 +852,7 @@ const adminPanelFallbackLocales = {
|
||||
messageFeature: 'Review recent SMS activity',
|
||||
callFeature: 'Review recent call activity',
|
||||
moderationFeature: 'Reset access, number, or device data',
|
||||
tonesFeature: 'Manage ringtones and notification sounds',
|
||||
auditFeature: 'Review sensitive admin actions',
|
||||
configuratorFeature: 'Manage config.lua and media.lua through SQL',
|
||||
},
|
||||
@@ -886,6 +893,43 @@ const adminPanelFallbackLocales = {
|
||||
secretConfigured: 'Secret configured · enter a replacement',
|
||||
invalidValue: 'Check the highlighted table or number value.',
|
||||
saved: 'SQL configuration saved and applied.',
|
||||
customTones: {
|
||||
context: 'Sound library',
|
||||
eyebrow: 'Audio management',
|
||||
library: 'Library',
|
||||
title: 'Custom ringtones and notification sounds',
|
||||
body: 'Manage local audio files from the database or config.lua without external URLs.',
|
||||
configTitle: 'File-based alternative',
|
||||
configBody:
|
||||
'If the FiveM client does not open a file dialog, place the file in the resource folder and register it in config.lua.',
|
||||
configSource: 'config.lua',
|
||||
configManaged:
|
||||
'This tone is managed through config.lua and can only be previewed here.',
|
||||
name: 'Display name',
|
||||
namePlaceholder: 'For example Dispatch',
|
||||
category: 'Use as',
|
||||
ringtone: 'Ringtone',
|
||||
notification: 'Notification sound',
|
||||
chooseFile: 'Choose audio file',
|
||||
fileHint: 'MP3, OGG, WAV, or WebM · up to 2 MB and 30 seconds',
|
||||
preview: 'Preview',
|
||||
add: 'Add tone',
|
||||
loading: 'Loading tone library...',
|
||||
ringtones: 'Ringtones',
|
||||
notifications: 'Notification sounds',
|
||||
empty: 'No custom tones in this category yet.',
|
||||
delete: 'Delete tone',
|
||||
confirmDelete: 'Click again to confirm',
|
||||
saved: 'The tone was saved and is immediately available on every phone.',
|
||||
deleted: 'The tone was deleted.',
|
||||
errors: {
|
||||
type: 'Choose an MP3, OGG, WAV, or WebM audio file.',
|
||||
size: 'The audio file may not exceed 2 MB.',
|
||||
duration: 'The tone must be between 0.25 and 30 seconds long.',
|
||||
invalid: 'The audio file could not be read.',
|
||||
playback: 'The tone could not be played.',
|
||||
},
|
||||
},
|
||||
descriptions: {
|
||||
featureToggle: 'Turns {name} on or off.',
|
||||
boolean: 'Controls whether {name} is allowed.',
|
||||
@@ -1155,6 +1199,8 @@ const adminPanelFallbackLocales = {
|
||||
change_number: 'Phone number changed',
|
||||
factory_reset: 'Phone factory reset',
|
||||
save_configuration: 'Configuration saved',
|
||||
create_custom_tone: 'Custom tone added',
|
||||
delete_custom_tone: 'Custom tone deleted',
|
||||
},
|
||||
},
|
||||
errors: {
|
||||
@@ -1169,6 +1215,12 @@ const adminPanelFallbackLocales = {
|
||||
configurator_disabled: 'Enable the phone configurator in config.lua first.',
|
||||
invalid_field: 'That configuration field is no longer available.',
|
||||
invalid_value: 'A configuration value is invalid.',
|
||||
invalid_tone: 'Check the tone name, file type, file size, and duration.',
|
||||
tone_name_taken: 'A tone with this name already exists in this category.',
|
||||
tone_limit: 'This category already contains 32 custom tones.',
|
||||
tone_not_found: 'That tone no longer exists.',
|
||||
invalid_upload: 'The tone upload is incomplete or invalid.',
|
||||
operation_in_progress: 'Another tone upload is already in progress.',
|
||||
account_not_found: 'No iFruit account is linked to this phone.',
|
||||
invalid_phone_number:
|
||||
'Enter a phone number in the configured server format.',
|
||||
@@ -5684,6 +5736,8 @@ export const usePhoneStore = defineStore('phone', {
|
||||
state: () => ({
|
||||
cameraLandscape: false,
|
||||
currentPage: 1,
|
||||
customTones: cloneJsonData(EMPTY_CUSTOM_PHONE_TONES),
|
||||
customTonesLoaded: false,
|
||||
device: null as PhoneDevice | null,
|
||||
deviceRevisions: {} as Record<string, number>,
|
||||
deviceSessionToken: null as string | null,
|
||||
@@ -5727,6 +5781,36 @@ export const usePhoneStore = defineStore('phone', {
|
||||
this.locales = locales
|
||||
this.fallbackLocales = fallbackLocales
|
||||
},
|
||||
setCustomTones(payload: unknown): void {
|
||||
this.customTones = parseCustomPhoneToneCatalog(payload)
|
||||
this.customTonesLoaded = true
|
||||
this.reconcileCustomTonePreferences()
|
||||
},
|
||||
reconcileCustomTonePreferences(): void {
|
||||
if (!this.customTonesLoaded) return
|
||||
let changed = false
|
||||
const ringtone = this.preferences.settings.ringtone
|
||||
if (
|
||||
isCustomTonePreferenceId(ringtone) &&
|
||||
!this.customTones.ringtones.some((tone) => tone.id === ringtone)
|
||||
) {
|
||||
this.preferences.settings.ringtone = 'skyline'
|
||||
changed = true
|
||||
}
|
||||
const notificationSound = this.preferences.settings.notificationSound
|
||||
if (
|
||||
isCustomTonePreferenceId(notificationSound) &&
|
||||
!this.customTones.notificationSounds.some(
|
||||
(tone) => tone.id === notificationSound,
|
||||
)
|
||||
) {
|
||||
this.preferences.settings.notificationSound = 'chime'
|
||||
changed = true
|
||||
}
|
||||
if (changed && this.device) {
|
||||
this.saveDeviceNamespace('settings', this.preferences)
|
||||
}
|
||||
},
|
||||
open(payload: PhoneOpenPayload = {}): void {
|
||||
const nextImei = payload.device?.imei ?? this.device?.imei ?? null
|
||||
const nextToken = payload.token ?? this.deviceSessionToken
|
||||
@@ -5741,6 +5825,7 @@ export const usePhoneStore = defineStore('phone', {
|
||||
this.fallbackLocales = payload.fallbackLocales ?? defaultLocales
|
||||
this.locales = payload.locales ?? this.fallbackLocales
|
||||
if (payload.device) this.hydrateDevice(payload.device)
|
||||
this.reconcileCustomTonePreferences()
|
||||
if (payload.player) this.player = payload.player
|
||||
this.security = payload.security ?? {
|
||||
enabled: false,
|
||||
|
||||
@@ -186,3 +186,23 @@ export type AdminConfiguratorChange = {
|
||||
scope: 'config' | 'media'
|
||||
value: unknown
|
||||
}
|
||||
|
||||
export type AdminCustomTone = {
|
||||
byteSize: number
|
||||
createdAt: string
|
||||
createdBy: string
|
||||
durationMs: number
|
||||
id: string
|
||||
label: string
|
||||
mimeType: string
|
||||
source: 'config' | 'database'
|
||||
toneType: 'notification' | 'ringtone'
|
||||
}
|
||||
|
||||
export type AdminCustomToneCreate = {
|
||||
durationMs: number
|
||||
label: string
|
||||
mimeType: string
|
||||
payload: string
|
||||
toneType: 'notification' | 'ringtone'
|
||||
}
|
||||
|
||||
@@ -5,10 +5,10 @@ import type {
|
||||
SkyPhoneAppCapability,
|
||||
} from '@/types/apps'
|
||||
import type { NuiResponse } from '@/utils/nui'
|
||||
import type { NotificationSoundId } from '@/utils/preferences'
|
||||
import type { BuiltInNotificationSoundId } from '@/utils/preferences'
|
||||
|
||||
const STORAGE_KEY_PATTERN = /^[A-Za-z0-9._-]{1,64}$/
|
||||
const NOTIFICATION_SOUNDS: ReadonlySet<NotificationSoundId> = new Set([
|
||||
const NOTIFICATION_SOUNDS: ReadonlySet<BuiltInNotificationSoundId> = new Set([
|
||||
'chime',
|
||||
'signal',
|
||||
'soft',
|
||||
@@ -24,7 +24,7 @@ type JsonResult = { success: true; value: unknown } | { success: false }
|
||||
export type CustomAppBridgeNotification = {
|
||||
appId: string
|
||||
route: string
|
||||
sound?: NotificationSoundId
|
||||
sound?: BuiltInNotificationSoundId
|
||||
subtitle?: string
|
||||
text: string
|
||||
title: string
|
||||
@@ -210,8 +210,8 @@ function normalizeNotificationPayload(
|
||||
const sound =
|
||||
payload.sound === undefined ||
|
||||
(typeof payload.sound === 'string' &&
|
||||
NOTIFICATION_SOUNDS.has(payload.sound as NotificationSoundId))
|
||||
? (payload.sound as NotificationSoundId | undefined)
|
||||
NOTIFICATION_SOUNDS.has(payload.sound as BuiltInNotificationSoundId))
|
||||
? (payload.sound as BuiltInNotificationSoundId | undefined)
|
||||
: null
|
||||
if (sound === null) return null
|
||||
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { nuiCall } from '@/utils/nui'
|
||||
import { playPhoneMediaTone } from '@/utils/tones'
|
||||
|
||||
import {
|
||||
CUSTOM_TONE_CHUNK_CHARS,
|
||||
MAX_CUSTOM_TONE_PAYLOAD_CHARS,
|
||||
findCustomPhoneTone,
|
||||
isCustomTonePreferenceId,
|
||||
parseCustomPhoneToneCatalog,
|
||||
playCustomPhoneTone,
|
||||
} from './customTones'
|
||||
|
||||
vi.mock('@/utils/nui', () => ({ nuiCall: vi.fn() }))
|
||||
vi.mock('@/utils/tones', () => ({ playPhoneMediaTone: vi.fn() }))
|
||||
|
||||
const DISPATCH_ID = '1fbd07b4-d231-4a76-b661-c9e5d0ab19a8'
|
||||
const RETRO_ID = 'a1e8db72-bc56-41c8-84e2-521180e14db1'
|
||||
|
||||
function tone(
|
||||
id: string,
|
||||
label: string,
|
||||
toneType: 'notification' | 'ringtone',
|
||||
byteSize = 120_000,
|
||||
) {
|
||||
return {
|
||||
byteSize,
|
||||
createdAt: '2026-08-27 12:00:00',
|
||||
durationMs: 8_500,
|
||||
id,
|
||||
label,
|
||||
mimeType: 'audio/mpeg',
|
||||
toneType,
|
||||
}
|
||||
}
|
||||
|
||||
describe('custom phone tones', () => {
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
|
||||
it('keeps transfer chunks below the reliable event ceiling and Base64-aligned', () => {
|
||||
expect(CUSTOM_TONE_CHUNK_CHARS).toBe(8_000)
|
||||
expect(CUSTOM_TONE_CHUNK_CHARS % 4).toBe(0)
|
||||
expect(
|
||||
Math.ceil(MAX_CUSTOM_TONE_PAYLOAD_CHARS / CUSTOM_TONE_CHUNK_CHARS),
|
||||
).toBe(334)
|
||||
})
|
||||
|
||||
it('accepts valid stored-audio catalog entries and creates namespaced preference ids', () => {
|
||||
const catalog = parseCustomPhoneToneCatalog({
|
||||
notificationSounds: [tone(DISPATCH_ID, 'Dispatch Alert', 'notification')],
|
||||
ringtones: [tone(RETRO_ID, 'Retro Ring', 'ringtone')],
|
||||
})
|
||||
|
||||
expect(catalog.ringtones[0]).toMatchObject({
|
||||
id: `custom:${RETRO_ID}`,
|
||||
label: 'Retro Ring',
|
||||
mimeType: 'audio/mpeg',
|
||||
})
|
||||
expect(
|
||||
findCustomPhoneTone(catalog.notificationSounds, `custom:${DISPATCH_ID}`)
|
||||
?.label,
|
||||
).toBe('Dispatch Alert')
|
||||
})
|
||||
|
||||
it('rejects malformed ids, mismatched categories, duplicates and blank labels', () => {
|
||||
const catalog = parseCustomPhoneToneCatalog({
|
||||
notificationSounds: [],
|
||||
ringtones: [
|
||||
tone('../escape', 'Escape', 'ringtone'),
|
||||
tone(DISPATCH_ID, 'Wrong category', 'notification'),
|
||||
tone(RETRO_ID, ' ', 'ringtone'),
|
||||
tone(DISPATCH_ID, 'Safe', 'ringtone'),
|
||||
tone(DISPATCH_ID, 'Duplicate', 'ringtone'),
|
||||
],
|
||||
})
|
||||
|
||||
expect(catalog.ringtones).toHaveLength(1)
|
||||
expect(catalog.ringtones[0]).toMatchObject({
|
||||
id: `custom:${DISPATCH_ID}`,
|
||||
label: 'Safe',
|
||||
})
|
||||
expect(isCustomTonePreferenceId(`custom:${DISPATCH_ID}`)).toBe(true)
|
||||
expect(isCustomTonePreferenceId('custom:../escape')).toBe(false)
|
||||
})
|
||||
|
||||
it('accepts safe config-file tone ids in their matching category', () => {
|
||||
const catalog = parseCustomPhoneToneCatalog({
|
||||
notificationSounds: [],
|
||||
ringtones: [
|
||||
{
|
||||
...tone('config:ringtone:dispatch_call', 'Dispatch Call', 'ringtone'),
|
||||
source: 'config',
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
expect(catalog.ringtones[0]).toMatchObject({
|
||||
id: 'custom:config:ringtone:dispatch_call',
|
||||
source: 'config',
|
||||
})
|
||||
expect(
|
||||
isCustomTonePreferenceId('custom:config:ringtone:dispatch_call'),
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts tones above the old 270 KB limit up to 2 MB', () => {
|
||||
const catalog = parseCustomPhoneToneCatalog({
|
||||
notificationSounds: [],
|
||||
ringtones: [
|
||||
tone(DISPATCH_ID, 'Large valid tone', 'ringtone', 1_500_000),
|
||||
tone(RETRO_ID, 'Too large', 'ringtone', 2_000_001),
|
||||
],
|
||||
})
|
||||
|
||||
expect(catalog.ringtones).toHaveLength(1)
|
||||
expect(catalog.ringtones[0]?.byteSize).toBe(1_500_000)
|
||||
})
|
||||
|
||||
it('loads preview audio in one NUI request before starting playback', async () => {
|
||||
const customTone = parseCustomPhoneToneCatalog({
|
||||
notificationSounds: [],
|
||||
ringtones: [tone(DISPATCH_ID, 'Fast preview', 'ringtone')],
|
||||
}).ringtones[0]!
|
||||
const onStarted = vi.fn()
|
||||
const stopPlayback = vi.fn()
|
||||
vi.mocked(nuiCall).mockResolvedValue({
|
||||
data: {
|
||||
id: DISPATCH_ID,
|
||||
mimeType: 'audio/ogg',
|
||||
payload: 'T2dnUw==',
|
||||
},
|
||||
success: true,
|
||||
})
|
||||
vi.mocked(playPhoneMediaTone).mockImplementation(
|
||||
(_url, _volume, _loop, callbacks) => {
|
||||
callbacks?.onStarted?.()
|
||||
return stopPlayback
|
||||
},
|
||||
)
|
||||
|
||||
const stop = playCustomPhoneTone(customTone, 70, false, { onStarted })
|
||||
|
||||
await vi.waitFor(() => expect(onStarted).toHaveBeenCalledOnce())
|
||||
expect(nuiCall).toHaveBeenCalledOnce()
|
||||
expect(nuiCall).toHaveBeenCalledWith('tones:audio', { id: DISPATCH_ID })
|
||||
expect(playPhoneMediaTone).toHaveBeenCalledWith(
|
||||
'data:audio/ogg;base64,T2dnUw==',
|
||||
70,
|
||||
false,
|
||||
{ onStarted },
|
||||
)
|
||||
stop()
|
||||
expect(stopPlayback).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,240 @@
|
||||
import { nuiCall } from '@/utils/nui'
|
||||
import { playPhoneMediaTone } from '@/utils/tones'
|
||||
|
||||
export type CustomTonePreferenceId = `custom:${string}`
|
||||
export type CustomToneType = 'notification' | 'ringtone'
|
||||
|
||||
export type CustomPhoneTone = {
|
||||
byteSize: number
|
||||
createdAt: string
|
||||
durationMs: number
|
||||
id: CustomTonePreferenceId
|
||||
label: string
|
||||
mimeType: string
|
||||
source: 'config' | 'database'
|
||||
toneType: CustomToneType
|
||||
}
|
||||
|
||||
export type CustomPhoneToneCatalog = {
|
||||
notificationSounds: CustomPhoneTone[]
|
||||
ringtones: CustomPhoneTone[]
|
||||
}
|
||||
|
||||
type CustomToneAudio = {
|
||||
id: string
|
||||
mimeType: string
|
||||
payload: string
|
||||
}
|
||||
|
||||
export const EMPTY_CUSTOM_PHONE_TONES: CustomPhoneToneCatalog = {
|
||||
notificationSounds: [],
|
||||
ringtones: [],
|
||||
}
|
||||
|
||||
export const MAX_CUSTOM_TONE_BYTES = 2_000_000
|
||||
export const MAX_CUSTOM_TONE_PAYLOAD_CHARS = 2_666_668
|
||||
export const CUSTOM_TONE_CHUNK_CHARS = 8_000
|
||||
|
||||
const CUSTOM_TONE_ID_PATTERN =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
||||
const CONFIG_TONE_ID_PATTERN =
|
||||
/^config:(ringtone|notification):[a-z0-9][a-z0-9_-]{0,47}$/
|
||||
const MAX_CUSTOM_TONES_PER_TYPE = 32
|
||||
const MAX_CACHED_TONES = 2
|
||||
const ALLOWED_MIME_TYPES = new Set([
|
||||
'audio/mpeg',
|
||||
'audio/ogg',
|
||||
'audio/wav',
|
||||
'audio/webm',
|
||||
])
|
||||
const cachedSources = new Map<string, string>()
|
||||
|
||||
export function isCustomTonePreferenceId(
|
||||
value: unknown,
|
||||
): value is CustomTonePreferenceId {
|
||||
return (
|
||||
typeof value === 'string' &&
|
||||
value.startsWith('custom:') &&
|
||||
(CUSTOM_TONE_ID_PATTERN.test(value.slice('custom:'.length)) ||
|
||||
CONFIG_TONE_ID_PATTERN.test(value.slice('custom:'.length)))
|
||||
)
|
||||
}
|
||||
|
||||
function parseToneList(
|
||||
value: unknown,
|
||||
expectedType: CustomToneType,
|
||||
): CustomPhoneTone[] {
|
||||
if (!Array.isArray(value)) return []
|
||||
|
||||
const tones: CustomPhoneTone[] = []
|
||||
const acceptedIds = new Set<string>()
|
||||
for (const entry of value.slice(0, MAX_CUSTOM_TONES_PER_TYPE)) {
|
||||
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) continue
|
||||
const candidate = entry as Record<string, unknown>
|
||||
const source = candidate.source === 'config' ? 'config' : 'database'
|
||||
const validId =
|
||||
typeof candidate.id === 'string' &&
|
||||
(source === 'config'
|
||||
? CONFIG_TONE_ID_PATTERN.test(candidate.id) &&
|
||||
candidate.id.startsWith(`config:${expectedType}:`)
|
||||
: CUSTOM_TONE_ID_PATTERN.test(candidate.id))
|
||||
if (
|
||||
!validId ||
|
||||
typeof candidate.id !== 'string' ||
|
||||
acceptedIds.has(candidate.id) ||
|
||||
typeof candidate.label !== 'string' ||
|
||||
candidate.label.length < 1 ||
|
||||
candidate.label.length > 64 ||
|
||||
candidate.label.trim().length < 1 ||
|
||||
candidate.toneType !== expectedType ||
|
||||
typeof candidate.mimeType !== 'string' ||
|
||||
!ALLOWED_MIME_TYPES.has(candidate.mimeType) ||
|
||||
typeof candidate.durationMs !== 'number' ||
|
||||
!Number.isInteger(candidate.durationMs) ||
|
||||
candidate.durationMs < 250 ||
|
||||
candidate.durationMs > 30_000 ||
|
||||
typeof candidate.byteSize !== 'number' ||
|
||||
!Number.isInteger(candidate.byteSize) ||
|
||||
candidate.byteSize < 1 ||
|
||||
candidate.byteSize > MAX_CUSTOM_TONE_BYTES ||
|
||||
typeof candidate.createdAt !== 'string'
|
||||
) {
|
||||
continue
|
||||
}
|
||||
|
||||
acceptedIds.add(candidate.id)
|
||||
tones.push({
|
||||
byteSize: candidate.byteSize,
|
||||
createdAt: candidate.createdAt,
|
||||
durationMs: candidate.durationMs,
|
||||
id: `custom:${candidate.id}`,
|
||||
label: candidate.label,
|
||||
mimeType: candidate.mimeType,
|
||||
source,
|
||||
toneType: expectedType,
|
||||
})
|
||||
}
|
||||
return tones
|
||||
}
|
||||
|
||||
export function parseCustomPhoneToneCatalog(
|
||||
value: unknown,
|
||||
): CustomPhoneToneCatalog {
|
||||
const source =
|
||||
value && typeof value === 'object' && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: {}
|
||||
return {
|
||||
notificationSounds: parseToneList(
|
||||
source.notificationSounds,
|
||||
'notification',
|
||||
),
|
||||
ringtones: parseToneList(source.ringtones, 'ringtone'),
|
||||
}
|
||||
}
|
||||
|
||||
export function findCustomPhoneTone(
|
||||
tones: CustomPhoneTone[],
|
||||
id: string,
|
||||
): CustomPhoneTone | undefined {
|
||||
return isCustomTonePreferenceId(id)
|
||||
? tones.find((tone) => tone.id === id)
|
||||
: undefined
|
||||
}
|
||||
|
||||
function cacheSource(id: string, source: string): void {
|
||||
cachedSources.delete(id)
|
||||
cachedSources.set(id, source)
|
||||
while (cachedSources.size > MAX_CACHED_TONES) {
|
||||
const oldest = cachedSources.keys().next().value
|
||||
if (typeof oldest !== 'string') break
|
||||
cachedSources.delete(oldest)
|
||||
}
|
||||
}
|
||||
|
||||
export function cacheCustomPhoneTonePayload(
|
||||
id: CustomTonePreferenceId,
|
||||
mimeType: string,
|
||||
payload: string,
|
||||
): boolean {
|
||||
if (
|
||||
!isCustomTonePreferenceId(id) ||
|
||||
!ALLOWED_MIME_TYPES.has(mimeType) ||
|
||||
payload.length < 1 ||
|
||||
payload.length > MAX_CUSTOM_TONE_PAYLOAD_CHARS ||
|
||||
payload.length % 4 !== 0 ||
|
||||
!/^[A-Za-z0-9+/]*={0,2}$/.test(payload)
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
cacheSource(id.slice('custom:'.length), `data:${mimeType};base64,${payload}`)
|
||||
return true
|
||||
}
|
||||
|
||||
async function loadToneSource(tone: CustomPhoneTone): Promise<string> {
|
||||
const rawId = tone.id.slice('custom:'.length)
|
||||
const cached = cachedSources.get(rawId)
|
||||
if (cached) {
|
||||
cacheSource(rawId, cached)
|
||||
return cached
|
||||
}
|
||||
|
||||
const response = await nuiCall<CustomToneAudio>('tones:audio', {
|
||||
id: rawId,
|
||||
})
|
||||
const audio = response.data
|
||||
if (
|
||||
!response.success ||
|
||||
!audio ||
|
||||
audio.id !== rawId ||
|
||||
!ALLOWED_MIME_TYPES.has(audio.mimeType) ||
|
||||
typeof audio.payload !== 'string' ||
|
||||
audio.payload.length < 1 ||
|
||||
audio.payload.length > MAX_CUSTOM_TONE_PAYLOAD_CHARS ||
|
||||
audio.payload.length % 4 !== 0 ||
|
||||
!/^[A-Za-z0-9+/]*={0,2}$/.test(audio.payload)
|
||||
) {
|
||||
throw new Error(response.error ?? 'invalid_custom_tone')
|
||||
}
|
||||
|
||||
const source = `data:${audio.mimeType};base64,${audio.payload}`
|
||||
cacheSource(rawId, source)
|
||||
return source
|
||||
}
|
||||
|
||||
export function playCustomPhoneTone(
|
||||
tone: CustomPhoneTone,
|
||||
volumePercent: number,
|
||||
loop: boolean,
|
||||
callbacks: {
|
||||
onError?: (error: unknown) => void
|
||||
onStarted?: () => void
|
||||
} = {},
|
||||
): () => void {
|
||||
let stopped = false
|
||||
let stopPlayback: (() => void) | null = null
|
||||
void loadToneSource(tone)
|
||||
.then((source) => {
|
||||
if (!stopped) {
|
||||
stopPlayback = playPhoneMediaTone(
|
||||
source,
|
||||
volumePercent,
|
||||
loop,
|
||||
callbacks,
|
||||
)
|
||||
}
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (!stopped) {
|
||||
console.error('[Phone audio] Failed to load custom tone', error)
|
||||
callbacks.onError?.(error)
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
stopped = true
|
||||
stopPlayback?.()
|
||||
stopPlayback = null
|
||||
}
|
||||
}
|
||||
@@ -75,6 +75,12 @@ export function registerPhoneMediaElement<T extends HTMLMediaElement>(
|
||||
return trackPhoneMediaElement(element, true)
|
||||
}
|
||||
|
||||
export function unregisterPhoneMediaElement(element: HTMLMediaElement): void {
|
||||
element.removeEventListener('volumechange', onMediaVolumeChange)
|
||||
mediaElements.delete(element)
|
||||
mediaLocalVolumes.delete(element)
|
||||
}
|
||||
|
||||
export function setPhoneOutputVolume(volume: number): void {
|
||||
outputVolume = clampVolume(volume)
|
||||
for (const [element, persistent] of mediaElements) {
|
||||
|
||||
@@ -123,6 +123,38 @@ describe('preferences', () => {
|
||||
expect(value.settings.screenBrightness).toBe(10)
|
||||
})
|
||||
|
||||
it('preserves valid custom tone ids without persisting their URLs', () => {
|
||||
const notificationId = 'custom:1fbd07b4-d231-4a76-b661-c9e5d0ab19a8'
|
||||
const ringtoneId = 'custom:a1e8db72-bc56-41c8-84e2-521180e14db1'
|
||||
const value = parsePhonePreferences(
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
settings: {
|
||||
notificationSound: notificationId,
|
||||
ringtone: ringtoneId,
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
expect(value.settings.notificationSound).toBe(notificationId)
|
||||
expect(value.settings.ringtone).toBe(ringtoneId)
|
||||
})
|
||||
|
||||
it('rejects malformed custom tone ids', () => {
|
||||
const value = parsePhonePreferences(
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
settings: {
|
||||
notificationSound: 'custom:../unsafe',
|
||||
ringtone: 'custom:',
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
expect(value.settings.notificationSound).toBe('chime')
|
||||
expect(value.settings.ringtone).toBe('skyline')
|
||||
})
|
||||
|
||||
it('keeps the phone above the minimum usable scale', () => {
|
||||
const value = parsePhonePreferences(
|
||||
JSON.stringify({
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import type { LaunchablePhoneAppId } from '@/types/apps'
|
||||
import { cloneJsonData } from '@/utils/clone'
|
||||
import {
|
||||
isCustomTonePreferenceId,
|
||||
type CustomTonePreferenceId,
|
||||
} from '@/utils/customTones'
|
||||
|
||||
export const APPEARANCE_MODE_IDS = ['automatic', 'light', 'dark'] as const
|
||||
export const GRAPHICS_MODE_IDS = ['performance', 'ultimate'] as const
|
||||
@@ -44,8 +48,12 @@ export const PHONE_SETUP_LAST_STEP = 9
|
||||
export type AppearanceMode = (typeof APPEARANCE_MODE_IDS)[number]
|
||||
export type GraphicsMode = (typeof GRAPHICS_MODE_IDS)[number]
|
||||
export type PhoneFrameId = (typeof PHONE_FRAME_IDS)[number]
|
||||
export type RingtoneId = (typeof RINGTONE_IDS)[number]
|
||||
export type NotificationSoundId = (typeof NOTIFICATION_SOUND_IDS)[number]
|
||||
export type BuiltInRingtoneId = (typeof RINGTONE_IDS)[number]
|
||||
export type BuiltInNotificationSoundId = (typeof NOTIFICATION_SOUND_IDS)[number]
|
||||
export type RingtoneId = BuiltInRingtoneId | CustomTonePreferenceId
|
||||
export type NotificationSoundId =
|
||||
| BuiltInNotificationSoundId
|
||||
| CustomTonePreferenceId
|
||||
export type BuiltInWallpaperId = (typeof WALLPAPER_IDS)[number]
|
||||
export type WallpaperId = BuiltInWallpaperId | 'custom'
|
||||
export type WallpaperTarget = 'home' | 'lock'
|
||||
@@ -196,6 +204,33 @@ function readChoice<T extends string>(
|
||||
: fallback
|
||||
}
|
||||
|
||||
export function isBuiltInRingtoneId(
|
||||
value: unknown,
|
||||
): value is BuiltInRingtoneId {
|
||||
return (
|
||||
typeof value === 'string' &&
|
||||
RINGTONE_IDS.includes(value as BuiltInRingtoneId)
|
||||
)
|
||||
}
|
||||
|
||||
export function isBuiltInNotificationSoundId(
|
||||
value: unknown,
|
||||
): value is BuiltInNotificationSoundId {
|
||||
return (
|
||||
typeof value === 'string' &&
|
||||
NOTIFICATION_SOUND_IDS.includes(value as BuiltInNotificationSoundId)
|
||||
)
|
||||
}
|
||||
|
||||
function readToneChoice<T extends string>(
|
||||
value: unknown,
|
||||
choices: readonly T[],
|
||||
fallback: T,
|
||||
): T | CustomTonePreferenceId {
|
||||
if (isCustomTonePreferenceId(value)) return value
|
||||
return readChoice(value, choices, fallback)
|
||||
}
|
||||
|
||||
function readWallpaperImageUrl(value: unknown): string | null {
|
||||
if (typeof value !== 'string') return null
|
||||
const imageUrl = value.trim()
|
||||
@@ -328,7 +363,7 @@ export function parsePhonePreferences(raw: string | null): PhonePreferencesV1 {
|
||||
),
|
||||
lockWallpaper,
|
||||
lockWallpaperImageUrl,
|
||||
notificationSound: readChoice(
|
||||
notificationSound: readToneChoice(
|
||||
settings.notificationSound,
|
||||
NOTIFICATION_SOUND_IDS,
|
||||
defaults.notificationSound,
|
||||
@@ -354,7 +389,7 @@ export function parsePhonePreferences(raw: string | null): PhonePreferencesV1 {
|
||||
Number.MAX_SAFE_INTEGER,
|
||||
),
|
||||
),
|
||||
ringtone: readChoice(
|
||||
ringtone: readToneChoice(
|
||||
settings.ringtone,
|
||||
RINGTONE_IDS,
|
||||
defaults.ringtone,
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { ALARM_SOUND_IDS } from './alarms'
|
||||
import { phoneToneDuration, playPhoneVibration } from './tones'
|
||||
import {
|
||||
phoneToneDuration,
|
||||
playPhoneMediaTone,
|
||||
playPhoneVibration,
|
||||
} from './tones'
|
||||
|
||||
describe('phone tones', () => {
|
||||
afterEach(() => vi.unstubAllGlobals())
|
||||
@@ -27,6 +31,8 @@ describe('phone tones', () => {
|
||||
pause: () => void
|
||||
play: () => Promise<void>
|
||||
preload: string
|
||||
load: () => void
|
||||
removeAttribute: (name: string) => void
|
||||
src: string
|
||||
volume: number
|
||||
}> = []
|
||||
@@ -41,6 +47,12 @@ describe('phone tones', () => {
|
||||
src: string
|
||||
volume = 0
|
||||
|
||||
load(): void {}
|
||||
|
||||
removeAttribute(name: string): void {
|
||||
if (name === 'src') this.src = ''
|
||||
}
|
||||
|
||||
constructor(src: string) {
|
||||
super()
|
||||
this.src = src
|
||||
@@ -63,4 +75,46 @@ describe('phone tones', () => {
|
||||
expect(players[0].currentTime).toBe(0)
|
||||
},
|
||||
)
|
||||
|
||||
it('reports media playback only after play has actually started', async () => {
|
||||
let resolvePlay: (() => void) | undefined
|
||||
const onError = vi.fn()
|
||||
const onStarted = vi.fn()
|
||||
vi.stubGlobal(
|
||||
'Audio',
|
||||
class extends EventTarget {
|
||||
loop = false
|
||||
preload = ''
|
||||
volume = 0
|
||||
|
||||
load(): void {}
|
||||
|
||||
pause(): void {}
|
||||
|
||||
play(): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
resolvePlay = resolve
|
||||
})
|
||||
}
|
||||
|
||||
removeAttribute(): void {}
|
||||
},
|
||||
)
|
||||
|
||||
const stop = playPhoneMediaTone(
|
||||
'data:audio/ogg;base64,T2dnUw==',
|
||||
75,
|
||||
false,
|
||||
{
|
||||
onError,
|
||||
onStarted,
|
||||
},
|
||||
)
|
||||
|
||||
expect(onStarted).not.toHaveBeenCalled()
|
||||
resolvePlay?.()
|
||||
await vi.waitFor(() => expect(onStarted).toHaveBeenCalledOnce())
|
||||
expect(onError).not.toHaveBeenCalled()
|
||||
stop()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { registerPhoneMediaElement } from '@/utils/phoneAudio'
|
||||
import {
|
||||
registerPhoneMediaElement,
|
||||
unregisterPhoneMediaElement,
|
||||
} from '@/utils/phoneAudio'
|
||||
import type { AlarmSoundId } from '@/utils/alarms'
|
||||
import type { NotificationSoundId } from '@/utils/preferences'
|
||||
import type { BuiltInNotificationSoundId } from '@/utils/preferences'
|
||||
|
||||
export type PhoneToneId = AlarmSoundId | NotificationSoundId
|
||||
export type PhoneToneId = AlarmSoundId | BuiltInNotificationSoundId
|
||||
export type PhoneVibrationKind = 'call' | 'notification'
|
||||
|
||||
type ToneVoice = {
|
||||
@@ -492,6 +495,44 @@ export function playPhoneTone(
|
||||
}
|
||||
}
|
||||
|
||||
export function playPhoneMediaTone(
|
||||
url: string,
|
||||
volumePercent: number,
|
||||
loop: boolean,
|
||||
callbacks: {
|
||||
onError?: (error: unknown) => void
|
||||
onStarted?: () => void
|
||||
} = {},
|
||||
): () => void {
|
||||
const player = new Audio(url)
|
||||
player.loop = loop
|
||||
player.preload = 'auto'
|
||||
player.volume = Math.max(0, Math.min(1, volumePercent / 100))
|
||||
registerPhoneMediaElement(player)
|
||||
|
||||
let stopped = false
|
||||
void player
|
||||
.play()
|
||||
.then(() => {
|
||||
if (!stopped) callbacks.onStarted?.()
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (!stopped) {
|
||||
console.error('[Phone audio] Failed to start custom tone', error)
|
||||
callbacks.onError?.(error)
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
if (stopped) return
|
||||
stopped = true
|
||||
player.pause()
|
||||
player.removeAttribute('src')
|
||||
player.load()
|
||||
unregisterPhoneMediaElement(player)
|
||||
}
|
||||
}
|
||||
|
||||
export function playPhoneVibration(
|
||||
kind: PhoneVibrationKind,
|
||||
loop: boolean,
|
||||
@@ -510,8 +551,12 @@ export function playPhoneVibration(
|
||||
})
|
||||
|
||||
return () => {
|
||||
if (stopped) return
|
||||
stopped = true
|
||||
player.pause()
|
||||
player.currentTime = 0
|
||||
player.removeAttribute('src')
|
||||
player.load()
|
||||
unregisterPhoneMediaElement(player)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,6 +96,11 @@ import {
|
||||
type WallpaperTarget,
|
||||
} from '@/utils/preferences'
|
||||
|
||||
type ToneChoice<T extends string> = {
|
||||
id: T
|
||||
label: string
|
||||
}
|
||||
|
||||
type SettingsView =
|
||||
| 'root'
|
||||
| 'account'
|
||||
@@ -618,6 +623,26 @@ function selectNotificationSound(sound: NotificationSoundId): void {
|
||||
phone.setPreference('notificationSound', sound)
|
||||
}
|
||||
|
||||
const ringtoneChoices = computed<ToneChoice<RingtoneId>[]>(() => [
|
||||
...RINGTONE_IDS.map((id) => ({
|
||||
id,
|
||||
label: phone.t('Apps.settings.ringtones.' + id),
|
||||
})),
|
||||
...phone.customTones.ringtones.map(({ id, label }) => ({ id, label })),
|
||||
])
|
||||
const notificationSoundChoices = computed<ToneChoice<NotificationSoundId>[]>(
|
||||
() => [
|
||||
...NOTIFICATION_SOUND_IDS.map((id) => ({
|
||||
id,
|
||||
label: phone.t('Apps.settings.notificationSoundsList.' + id),
|
||||
})),
|
||||
...phone.customTones.notificationSounds.map(({ id, label }) => ({
|
||||
id,
|
||||
label,
|
||||
})),
|
||||
],
|
||||
)
|
||||
|
||||
function updateAccountEmail(event: Event): void {
|
||||
const input = event.target as HTMLInputElement
|
||||
const original = input.value
|
||||
@@ -1276,23 +1301,25 @@ onBeforeUnmount(() => {
|
||||
|
||||
<SkySettingsGroup :title="phone.t('Apps.settings.ringtone')">
|
||||
<SkySettingsRow
|
||||
v-for="ringtone in RINGTONE_IDS"
|
||||
:key="ringtone"
|
||||
v-for="ringtone in ringtoneChoices"
|
||||
:key="ringtone.id"
|
||||
kind="choice"
|
||||
:selected="phone.preferences.settings.ringtone === ringtone"
|
||||
:title="phone.t('Apps.settings.ringtones.' + ringtone)"
|
||||
@activate="selectRingtone(ringtone)"
|
||||
:selected="phone.preferences.settings.ringtone === ringtone.id"
|
||||
:title="ringtone.label"
|
||||
@activate="selectRingtone(ringtone.id)"
|
||||
/>
|
||||
</SkySettingsGroup>
|
||||
|
||||
<SkySettingsGroup :title="phone.t('Apps.settings.notificationSound')">
|
||||
<SkySettingsRow
|
||||
v-for="sound in NOTIFICATION_SOUND_IDS"
|
||||
:key="sound"
|
||||
v-for="sound in notificationSoundChoices"
|
||||
:key="sound.id"
|
||||
kind="choice"
|
||||
:selected="phone.preferences.settings.notificationSound === sound"
|
||||
:title="phone.t('Apps.settings.notificationSoundsList.' + sound)"
|
||||
@activate="selectNotificationSound(sound)"
|
||||
:selected="
|
||||
phone.preferences.settings.notificationSound === sound.id
|
||||
"
|
||||
:title="sound.label"
|
||||
@activate="selectNotificationSound(sound.id)"
|
||||
/>
|
||||
</SkySettingsGroup>
|
||||
</template>
|
||||
|
||||
@@ -742,6 +742,7 @@ function loadConfiguratorSections() {
|
||||
const media = mediaRoot.Media
|
||||
delete config.PhoneConfigurator
|
||||
delete config.CommandPermissions
|
||||
delete config.CustomTones
|
||||
delete config.Media
|
||||
return [...buildSections('config', config), ...buildSections('media', media)]
|
||||
}
|
||||
|
||||
@@ -73,7 +73,8 @@ describe('admin configurator fixture', () => {
|
||||
(root) =>
|
||||
root !== 'Media' &&
|
||||
root !== 'PhoneConfigurator' &&
|
||||
root !== 'CommandPermissions',
|
||||
root !== 'CommandPermissions' &&
|
||||
root !== 'CustomTones',
|
||||
)
|
||||
|
||||
expect(sections).toHaveLength(46)
|
||||
|
||||
@@ -4933,6 +4933,13 @@ const adminMockConfigurator = {
|
||||
sections: loadConfiguratorSections(),
|
||||
}
|
||||
|
||||
const adminMockCustomTones = []
|
||||
const adminMockToneUploads = new Map()
|
||||
|
||||
function adminCustomToneList() {
|
||||
return adminMockCustomTones.map(({ payload: _payload, ...tone }) => tone)
|
||||
}
|
||||
|
||||
app.post('/api/:endpoint', async (request, response, next) => {
|
||||
const endpoint = request.params.endpoint
|
||||
const loggedBody = { ...request.body }
|
||||
@@ -4954,6 +4961,9 @@ app.post('/api/:endpoint', async (request, response, next) => {
|
||||
if (endpoint === 'memos:devCapture') {
|
||||
loggedBody.audioDataUrl = `<${String(request.body.audioDataUrl ?? '').length} characters>`
|
||||
}
|
||||
if (endpoint === 'admin:tone-upload-chunk') {
|
||||
loggedBody.chunk = `<${String(request.body.chunk ?? '').length} characters>`
|
||||
}
|
||||
console.log('[NUI]', endpoint, loggedBody)
|
||||
if (endpoint === 'music:bootstrap') {
|
||||
response.json({ success: true, data: musicBootstrap() })
|
||||
@@ -4967,6 +4977,87 @@ app.post('/api/:endpoint', async (request, response, next) => {
|
||||
response.json({ success: true, data: adminMockConfigurator })
|
||||
return
|
||||
}
|
||||
if (endpoint === 'admin:tones') {
|
||||
response.json({ success: true, data: adminCustomToneList() })
|
||||
return
|
||||
}
|
||||
if (endpoint === 'admin:tone-upload-start') {
|
||||
const uploadId = randomUUID()
|
||||
adminMockToneUploads.set(uploadId, {
|
||||
chunks: [],
|
||||
durationMs: Number(request.body.durationMs) || 1000,
|
||||
label: String(request.body.label ?? 'Custom tone'),
|
||||
mimeType: String(request.body.mimeType ?? 'audio/mpeg'),
|
||||
toneType:
|
||||
request.body.toneType === 'notification' ? 'notification' : 'ringtone',
|
||||
})
|
||||
response.json({ success: true, data: { uploadId } })
|
||||
return
|
||||
}
|
||||
if (endpoint === 'admin:tone-upload-chunk') {
|
||||
const upload = adminMockToneUploads.get(request.body.uploadId)
|
||||
if (!upload) {
|
||||
response.json({ success: false, error: 'invalid_upload' })
|
||||
return
|
||||
}
|
||||
upload.chunks.push(String(request.body.chunk ?? ''))
|
||||
response.json({ success: true })
|
||||
return
|
||||
}
|
||||
if (endpoint === 'admin:tone-upload-finish') {
|
||||
const upload = adminMockToneUploads.get(request.body.uploadId)
|
||||
if (!upload) {
|
||||
response.json({ success: false, error: 'invalid_upload' })
|
||||
return
|
||||
}
|
||||
adminMockToneUploads.delete(request.body.uploadId)
|
||||
const payload = upload.chunks.join('')
|
||||
adminMockCustomTones.push({
|
||||
byteSize: Buffer.from(payload, 'base64').byteLength,
|
||||
createdAt: new Date().toISOString(),
|
||||
createdBy: 'Development Admin',
|
||||
durationMs: upload.durationMs,
|
||||
id: randomUUID(),
|
||||
label: upload.label,
|
||||
mimeType: upload.mimeType,
|
||||
payload,
|
||||
source: 'database',
|
||||
toneType: upload.toneType,
|
||||
})
|
||||
response.json({ success: true, data: adminCustomToneList() })
|
||||
return
|
||||
}
|
||||
if (endpoint === 'admin:tone-upload-cancel') {
|
||||
adminMockToneUploads.delete(request.body.uploadId)
|
||||
response.json({ success: true })
|
||||
return
|
||||
}
|
||||
if (endpoint === 'admin:delete-tone') {
|
||||
const index = adminMockCustomTones.findIndex(
|
||||
(tone) => tone.id === request.body.id,
|
||||
)
|
||||
if (index >= 0) adminMockCustomTones.splice(index, 1)
|
||||
response.json({ success: true, data: adminCustomToneList() })
|
||||
return
|
||||
}
|
||||
if (endpoint === 'tones:audio') {
|
||||
const tone = adminMockCustomTones.find(
|
||||
(candidate) => candidate.id === request.body.id,
|
||||
)
|
||||
if (!tone) {
|
||||
response.json({ success: false, error: 'tone_not_found' })
|
||||
return
|
||||
}
|
||||
response.json({
|
||||
success: true,
|
||||
data: {
|
||||
id: tone.id,
|
||||
mimeType: tone.mimeType,
|
||||
payload: tone.payload,
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
if (endpoint === 'admin:save-configurator') {
|
||||
const changes = Array.isArray(request.body.changes)
|
||||
? request.body.changes
|
||||
|
||||
Reference in New Issue
Block a user