ADD - manage custom phone tones

This commit is contained in:
Dominik9906
2026-08-29 00:26:00 +02:00
parent efcc67334f
commit 3bf38ac5d0
37 changed files with 3153 additions and 51 deletions
+4
View File
@@ -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',
]) {
+130 -3
View File
@@ -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;
+117
View File
@@ -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"',
)
})
})
+6 -2
View File
@@ -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',
)
})
+101
View File
@@ -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,
+22 -7
View File
@@ -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(() => {
+24 -6
View File
@@ -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,
),
)
}
+85
View File
@@ -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,
+20
View File
@@ -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 -5
View File
@@ -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
+156
View File
@@ -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()
})
})
+240
View File
@@ -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
}
}
+6
View File
@@ -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) {
+32
View File
@@ -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({
+39 -4
View File
@@ -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,
+55 -1
View File
@@ -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()
})
})
+48 -3
View File
@@ -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)
}
}
+37 -10
View File
@@ -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)
+91
View File
@@ -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
+27
View File
@@ -61,6 +61,33 @@ Config.Phone = {
DeviceName = "iFruit Phone",
}
-- CONFIG_DEFAULT_EXCLUDE_START
-- Local custom sounds remain file-based even when the Phone Configurator is
-- enabled. Copy each audio file into config/custom_tones, add one entry below,
-- and restart sky_phone. Files are read and served by sky_phone itself; no URL
-- or external website is required. Supported: mp3, ogg, wav, webm.
-- Limits per file: 2 MB and 250-30000 ms. Id values must be unique and use
-- only lowercase letters, numbers, underscores, or hyphens.
Config.CustomTones = {
Ringtones = {
-- {
-- Id = "dispatch_call",
-- Label = "Dispatch Call",
-- File = "config/custom_tones/dispatch_call.ogg",
-- DurationMs = 8500,
-- },
},
NotificationSounds = {
-- {
-- Id = "dispatch_ping",
-- Label = "Dispatch Ping",
-- File = "config/custom_tones/dispatch_ping.ogg",
-- DurationMs = 900,
-- },
},
}
-- CONFIG_DEFAULT_EXCLUDE_END
-- Server-wide availability for bundled apps. Set an entry to false to hide it
-- from every phone, the App Store and per-device app management.
Config.Apps = {
+7
View File
@@ -0,0 +1,7 @@
# Local custom phone tones
Place local `.mp3`, `.ogg`, `.wav`, or `.webm` files in this directory and register them in
`Config.CustomTones` inside `config/config.lua`. Restart `sky_phone` after changing the files or the
configuration. The phone reads these files directly; no external URL or website is required.
Each file may be at most 2 MB and between 250 milliseconds and 30 seconds long.
+45 -2
View File
@@ -204,8 +204,8 @@ Locales["de"] = {
},
AdminPanel = {
name = "Phone Admin", subtitle = "Administration", navigation = "Admin-Navigation", refresh = "Admin-Daten aktualisieren", loading = "Geschützte Daten werden geladen...",
tabs = { overview = "Übersicht", players = "Spieler", devices = "Geräte", apps = "Apps", accounts = "Accounts", messages = "Nachrichten", calls = "Anrufe", moderation = "Moderation", audit = "Audit", configurator = "Phone Configurator" },
overview = { eyebrow = "Server", title = "Dashboard", body = "Spieler, Geräte, Apps und Handydaten.", stats = "Server-Handystatistik", online = "Online", devices = "Geräte", accounts = "Accounts", audit = "Audit-Einträge", control = "Navigation", features = "Module", featuresBody = "Öffne ein Verwaltungsmodul.", recent = "Letzte Aktivitäten", playerFeature = "Identität, Finanzen, Job und Dienst", deviceFeature = "IMEI, SIM, Nummer und Aktivität", appFeature = "Handy-Apps installieren oder entfernen", accountFeature = "Accountzugriff und geschützte Zugangsdaten", messageFeature = "Letzte SMS-Aktivitäten prüfen", callFeature = "Letzte Anrufaktivitäten prüfen", moderationFeature = "Zugriff, Nummer oder Gerätedaten zurücksetzen", auditFeature = "Sensible Admin-Aktionen prüfen", configuratorFeature = "config.lua und media.lua über SQL verwalten" },
tabs = { overview = "Übersicht", players = "Spieler", devices = "Geräte", apps = "Apps", accounts = "Accounts", messages = "Nachrichten", calls = "Anrufe", moderation = "Moderation", tones = "Töne", audit = "Audit", configurator = "Phone Configurator" },
overview = { eyebrow = "Server", title = "Dashboard", body = "Spieler, Geräte, Apps und Handydaten.", stats = "Server-Handystatistik", online = "Online", devices = "Geräte", accounts = "Accounts", audit = "Audit-Einträge", control = "Navigation", features = "Module", featuresBody = "Öffne ein Verwaltungsmodul.", recent = "Letzte Aktivitäten", playerFeature = "Identität, Finanzen, Job und Dienst", deviceFeature = "IMEI, SIM, Nummer und Aktivität", appFeature = "Handy-Apps installieren oder entfernen", accountFeature = "Accountzugriff und geschützte Zugangsdaten", messageFeature = "Letzte SMS-Aktivitäten prüfen", callFeature = "Letzte Anrufaktivitäten prüfen", moderationFeature = "Zugriff, Nummer oder Gerätedaten zurücksetzen", tonesFeature = "Klingel- und Nachrichtentöne verwalten", auditFeature = "Sensible Admin-Aktionen prüfen", configuratorFeature = "config.lua und media.lua über SQL verwalten" },
statistics = { eyebrow = "Live-Daten", title = "Handy-Statistiken", body = "Aktuelle Nutzung und Gerätestatus, automatisch alle 15 Sekunden aktualisiert.", today = "Aktivität heute", todayBody = "Handy- und Admin-Aktivität seit Mitternacht.", messagesToday = "Nachrichten", callsToday = "Anrufe", auditToday = "Admin-Aktionen", coverage = "Geräteabdeckung", coverageBody = "Aktueller Anteil aller gespeicherten Handys.", linkedDevices = "Account verknüpft", simDevices = "SIM zugewiesen", activeDevices = "In 24 Stunden aktualisiert", ofDevices = "{count} von {total} Geräten" },
configurator = { context = "Runtime-Konfiguration", eyebrow = "Systemwerkzeug", sections = "Konfiguration", search = "Einstellungen oder Pfade suchen", configScope = "config.lua", mediaScope = "media.lua", noResults = "Keine passenden Einstellungen", loading = "SQL-Konfiguration wird geladen...", title = "Phone Configurator", body = "Verwalte Handy- und Media-Einstellungen im geschützten Admin-Bereich.", disabledTitle = "SQL-Konfiguration ist nicht aktiv", disabledBody = "Aktiviere den Configurator am Anfang der config.lua und starte sky_phone neu. Bis dahin bleiben die Dateiwerte aktiv und die Bearbeitung gesperrt.", manualSave = "Manuelles Speichern", refreshNotice = "Nichts wird automatisch gespeichert. Der grüne Haken prüft config.lua und media.lua in SQL und aktualisiert die aktive Server-, Client-, Media- und UI-Konfiguration sofort intern.", fieldCount = "{count} Felder", secretConfigured = "Secret gesetzt · Ersatzwert eingeben", invalidValue = "Prüfe den markierten Tabellen- oder Zahlenwert.", saved = "SQL-Konfiguration gespeichert und übernommen.", descriptions = { featureToggle = "Schaltet {name} ein oder aus.", boolean = "Legt fest, ob {name} erlaubt ist.", number = "Legt den Zahlenwert für {name} fest.", text = "Legt den Textwert für {name} fest.", optionalText = "Legt den optionalen Wert für {name} fest; der Schalter deaktiviert ihn.", list = "Verwaltet alle Einträge für {name}.", table = "Bündelt die zusammengehörigen Einstellungen für {name}.", credential = "Speichert den geschützten Zugangsschlüssel für {name}.", url = "Legt die URL oder den Endpunkt für {name} fest.", hosts = "Legt die erlaubten Domains für {name} fest.", milliseconds = "Legt die Zeit für {name} in Millisekunden fest.", seconds = "Legt die Zeit für {name} in Sekunden fest.", rateLimit = "Begrenzt die Aktionen für {name} pro Minute.", byteLimit = "Legt die maximal erlaubte Datengröße für {name} fest.", textLimit = "Legt die maximal erlaubte Textlänge für {name} fest.", distance = "Legt die Distanz in der Spielwelt für {name} fest.", coordinates = "Legt Weltkoordinaten oder Ausrichtung für {name} fest.", gameAsset = "Legt das GTA-Modell oder Prop für {name} fest.", animation = "Legt die Animationsdatei für {name} fest.", access = "Legt Jobs, Gruppen oder Berechtigungsstufen für {name} fest.", integration = "Wählt Framework oder Anbieter für {name} aus.", path = "Legt den Speicher- oder Ressourcenpfad für {name} fest.", color = "Legt die Oberflächenfarbe für {name} fest.", displayText = "Legt den Spielern angezeigten Text für {name} fest.", phoneNumber = "Legt die Telefon- oder Servicenummer für {name} fest.", routing = "Steuert die Verteilung eingehender Anfragen für {name}.", command = "Legt den Chat-Befehl zum Öffnen oder Ausführen von {name} fest.", locale = "Wählt die Sprache für {name} aus.", debug = "Steuert ausführliche Diagnoseausgaben für {name}.", mediaQuality = "Legt Medienqualität oder Lautstärke für {name} fest.", amount = "Legt die maximale oder angezeigte Menge für {name} fest." }, table = { list = "Liste", table = "Schlüsseltabelle", vector = "Vektor", entry = "Eintrag", general = "Allgemein", addRow = "Zeile hinzufügen", addField = "Feld hinzufügen", remove = "Entfernen", emptyList = "Noch keine Zeilen. Füge die erste Zeile mit Plus hinzu.", emptyTable = "Noch keine Felder. Füge unten den ersten Schlüssel hinzu.", keyPlaceholder = "Neuer Schlüssel", convertToList = "Als Liste nutzen", convertToMap = "Als typisierte Schlüsseltabelle nutzen", convertToTable = "Als Schlüsseltabelle nutzen", types = { string = "Text", number = "Zahl", boolean = "Schalter", list = "Liste", table = "Tabelle" } } },
players = { eyebrow = "Aktive Sitzungen", title = "Online-Spieler", online = "Jetzt online", empty = "Keine Spieler gefunden", emptyBody = "Passe die Suche an oder aktualisiere die Spielerliste." },
@@ -2178,3 +2178,46 @@ Locales["de"].Nui.AdminPanel.configurator.table.subtabs = {
AllowedJobs = "Erlaubte Jobs",
Websites = "Webseiten",
}
Locales["de"].Nui.AdminPanel.configurator.customTones = {
context = "Tonbibliothek",
eyebrow = "Audioverwaltung",
library = "Bibliothek",
title = "Eigene Klingel- und Nachrichtentöne",
body = "Verwalte lokale Audiodateien aus der Datenbank oder der config.lua ohne externe URLs.",
configTitle = "Dateibasierte Alternative",
configBody = "Wenn der FiveM-Client keinen Dateidialog öffnet, lege die Datei im Resource-Ordner ab und registriere sie in der config.lua.",
configSource = "config.lua",
configManaged = "Dieser Ton wird über die config.lua verwaltet und kann hier nur angehört werden.",
name = "Anzeigename",
namePlaceholder = "Zum Beispiel Leitstelle",
category = "Verwendung",
ringtone = "Klingelton",
notification = "Nachrichtenton",
chooseFile = "Audiodatei auswählen",
fileHint = "MP3, OGG, WAV oder WebM · maximal 2 MB und 30 Sekunden",
preview = "Anhören",
add = "Ton hinzufügen",
loading = "Tonbibliothek wird geladen...",
ringtones = "Klingeltöne",
notifications = "Nachrichtentöne",
empty = "Noch keine eigenen Töne in dieser Kategorie.",
delete = "Ton löschen",
confirmDelete = "Zum Bestätigen erneut klicken",
saved = "Der Ton wurde gespeichert und ist sofort auf allen Handys verfügbar.",
deleted = "Der Ton wurde gelöscht.",
errors = {
type = "Wähle eine MP3-, OGG-, WAV- oder WebM-Audiodatei.",
size = "Die Audiodatei darf höchstens 2 MB groß sein.",
duration = "Der Ton muss zwischen 0,25 und 30 Sekunden lang sein.",
invalid = "Die Audiodatei konnte nicht gelesen werden.",
playback = "Der Ton konnte nicht wiedergegeben werden.",
},
}
Locales["de"].Nui.AdminPanel.errors.invalid_tone = "Prüfe Name, Dateityp, Dateigröße und Länge des Tons."
Locales["de"].Nui.AdminPanel.errors.tone_name_taken = "In dieser Kategorie existiert bereits ein Ton mit diesem Namen."
Locales["de"].Nui.AdminPanel.errors.tone_limit = "In dieser Kategorie sind bereits 32 eigene Töne gespeichert."
Locales["de"].Nui.AdminPanel.errors.tone_not_found = "Dieser Ton existiert nicht mehr."
Locales["de"].Nui.AdminPanel.errors.invalid_upload = "Der Ton-Upload ist unvollständig oder ungültig."
Locales["de"].Nui.AdminPanel.errors.operation_in_progress = "Ein anderer Ton-Upload läuft bereits."
Locales["de"].Nui.AdminPanel.audit.actions.create_custom_tone = "Eigenen Ton hinzugefügt"
Locales["de"].Nui.AdminPanel.audit.actions.delete_custom_tone = "Eigenen Ton gelöscht"
+45 -2
View File
@@ -204,8 +204,8 @@ Locales["en"] = {
},
AdminPanel = {
name = "Phone Admin", subtitle = "Administration", navigation = "Admin navigation", refresh = "Refresh admin data", loading = "Loading protected data...",
tabs = { overview = "Overview", players = "Players", devices = "Devices", apps = "Apps", accounts = "Accounts", messages = "Messages", calls = "Calls", moderation = "Moderation", audit = "Audit", configurator = "Phone configurator" },
overview = { eyebrow = "Server", title = "Dashboard", body = "Players, devices, apps, and phone data.", stats = "Server phone statistics", online = "Online", devices = "Devices", accounts = "Accounts", audit = "Audit entries", control = "Navigation", features = "Modules", featuresBody = "Open an administration module.", recent = "Recent activity", playerFeature = "Identity, finances, job, and duty", deviceFeature = "IMEI, SIM, number, and activity", appFeature = "Install or remove phone apps", accountFeature = "Account access and protected credentials", messageFeature = "Review recent SMS activity", callFeature = "Review recent call activity", moderationFeature = "Reset access, number, or device data", auditFeature = "Review sensitive admin actions", configuratorFeature = "Manage config.lua and media.lua through SQL" },
tabs = { overview = "Overview", players = "Players", devices = "Devices", apps = "Apps", accounts = "Accounts", messages = "Messages", calls = "Calls", moderation = "Moderation", tones = "Sounds", audit = "Audit", configurator = "Phone configurator" },
overview = { eyebrow = "Server", title = "Dashboard", body = "Players, devices, apps, and phone data.", stats = "Server phone statistics", online = "Online", devices = "Devices", accounts = "Accounts", audit = "Audit entries", control = "Navigation", features = "Modules", featuresBody = "Open an administration module.", recent = "Recent activity", playerFeature = "Identity, finances, job, and duty", deviceFeature = "IMEI, SIM, number, and activity", appFeature = "Install or remove phone apps", accountFeature = "Account access and protected credentials", 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" },
statistics = { eyebrow = "Live data", title = "Phone statistics", body = "Current usage and device status, refreshed every 15 seconds.", today = "Activity today", todayBody = "Phone and admin activity since midnight.", messagesToday = "Messages", callsToday = "Calls", auditToday = "Admin actions", coverage = "Device coverage", coverageBody = "Current share of all stored phones.", linkedDevices = "Account linked", simDevices = "SIM assigned", activeDevices = "Updated in 24 hours", ofDevices = "{count} of {total} devices" },
configurator = { context = "Runtime configuration", eyebrow = "System tool", sections = "Configuration", search = "Search settings or paths", configScope = "config.lua", mediaScope = "media.lua", noResults = "No matching settings", loading = "Loading SQL configuration...", title = "Phone configurator", body = "Manage phone and media settings from the protected admin workspace.", disabledTitle = "SQL configuration is not active", disabledBody = "Enable the configurator at the beginning of config.lua and restart sky_phone. Until then, file values remain active and editing is locked.", manualSave = "Manual save", refreshNotice = "Nothing is written automatically. The green check verifies config.lua and media.lua in SQL and refreshes the active server, client, media and UI configuration immediately.", fieldCount = "{count} fields", secretConfigured = "Secret configured · enter a replacement", invalidValue = "Check the highlighted table or number value.", saved = "SQL configuration saved and applied.", descriptions = { featureToggle = "Turns {name} on or off.", boolean = "Controls whether {name} is allowed.", number = "Sets the numeric value for {name}.", text = "Sets the text value used for {name}.", optionalText = "Sets the optional value for {name}; switch it off to disable it.", list = "Manages all entries used for {name}.", table = "Groups the related settings for {name}.", credential = "Stores the protected credential used by {name}.", url = "Sets the URL or endpoint used by {name}.", hosts = "Defines which domains are allowed for {name}.", milliseconds = "Sets the timing for {name} in milliseconds.", seconds = "Sets the timing for {name} in seconds.", rateLimit = "Limits how many {name} actions are allowed per minute.", byteLimit = "Sets the maximum data size allowed for {name}.", textLimit = "Sets the maximum text length allowed for {name}.", distance = "Sets the world distance used for {name}.", coordinates = "Sets the world coordinates or orientation for {name}.", gameAsset = "Sets the GTA model or prop used for {name}.", animation = "Sets the animation asset used for {name}.", access = "Defines the jobs, groups or permission level for {name}.", integration = "Selects the connected framework or provider for {name}.", path = "Sets the storage or resource path used for {name}.", color = "Sets the interface color used for {name}.", displayText = "Sets the text shown to players for {name}.", phoneNumber = "Sets the phone or service number used for {name}.", routing = "Controls how incoming requests are routed for {name}.", command = "Sets the chat command used to open or run {name}.", locale = "Selects the language used for {name}.", debug = "Controls detailed diagnostic output for {name}.", mediaQuality = "Sets the media quality or volume used for {name}.", amount = "Sets the maximum or displayed amount for {name}." }, table = { list = "List", table = "Key table", vector = "Vector", entry = "Entry", general = "General", addRow = "Add row", addField = "Add field", remove = "Remove", emptyList = "No rows yet. Add the first row with plus.", emptyTable = "No fields yet. Add the first key below.", keyPlaceholder = "New key", convertToList = "Use list", convertToMap = "Use typed key table", convertToTable = "Use key table", types = { string = "Text", number = "Number", boolean = "Switch", list = "List", table = "Table" } } },
players = { eyebrow = "Active sessions", title = "Online players", online = "Online now", empty = "No players found", emptyBody = "Adjust the search or refresh the live player list." },
@@ -2178,3 +2178,46 @@ Locales["en"].Nui.AdminPanel.configurator.table.subtabs = {
AllowedJobs = "Allowed Jobs",
Websites = "Websites",
}
Locales["en"].Nui.AdminPanel.configurator.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.",
},
}
Locales["en"].Nui.AdminPanel.errors.invalid_tone = "Check the tone name, file type, file size, and duration."
Locales["en"].Nui.AdminPanel.errors.tone_name_taken = "A tone with this name already exists in this category."
Locales["en"].Nui.AdminPanel.errors.tone_limit = "This category already contains 32 custom tones."
Locales["en"].Nui.AdminPanel.errors.tone_not_found = "That tone no longer exists."
Locales["en"].Nui.AdminPanel.errors.invalid_upload = "The tone upload is incomplete or invalid."
Locales["en"].Nui.AdminPanel.errors.operation_in_progress = "Another tone upload is already in progress."
Locales["en"].Nui.AdminPanel.audit.actions.create_custom_tone = "Custom tone added"
Locales["en"].Nui.AdminPanel.audit.actions.delete_custom_tone = "Custom tone deleted"
+45 -2
View File
@@ -204,8 +204,8 @@ Locales["es"] = {
},
AdminPanel = {
name = "Administrador de teléfono", subtitle = "Administración", navigation = "Navegación del administrador", refresh = "Actualizar los datos del administrador", loading = "Cargar datos protegidos...",
tabs = { overview = "Sinopsis", players = "Los jugadores", devices = "Los dispositivos", apps = "Aplicaciones", accounts = "Cuentas", messages = "Mensajes", calls = "Las llamadas", moderation = "Moderación", audit = "Auditoría", configurator = "Configuración de teléfono" },
overview = { eyebrow = "El servidor", title = "Tablero de control", body = "Jugadores, dispositivos, aplicaciones y datos telefónicos.", stats = "Estadísticas telefónicas del servidor", online = "En línea", devices = "Los dispositivos", accounts = "Cuentas", audit = "Entradas de auditoría", control = "Navegación", features = "Los módulos", featuresBody = "Abre un módulo de administración.", recent = "Actividad reciente", playerFeature = "Identidad, finanzas, trabajo y deber", deviceFeature = "IMEI, SIM, número y actividad", appFeature = "Instalar o eliminar aplicaciones telefónicas", accountFeature = "Acceso a la cuenta y credenciales protegidas", messageFeature = "Revisar la actividad reciente de SMS", callFeature = "Revisar la actividad reciente de las llamadas", moderationFeature = "Resetear datos de acceso, número o dispositivo", auditFeature = "Revisar las acciones del administrador sensibles", configuratorFeature = "Administrar config.lua y media.lua a través de SQL" },
tabs = { overview = "Sinopsis", players = "Los jugadores", devices = "Los dispositivos", apps = "Aplicaciones", accounts = "Cuentas", messages = "Mensajes", calls = "Las llamadas", moderation = "Moderación", tones = "Sonidos", audit = "Auditoría", configurator = "Configuración de teléfono" },
overview = { eyebrow = "El servidor", title = "Tablero de control", body = "Jugadores, dispositivos, aplicaciones y datos telefónicos.", stats = "Estadísticas telefónicas del servidor", online = "En línea", devices = "Los dispositivos", accounts = "Cuentas", audit = "Entradas de auditoría", control = "Navegación", features = "Los módulos", featuresBody = "Abre un módulo de administración.", recent = "Actividad reciente", playerFeature = "Identidad, finanzas, trabajo y deber", deviceFeature = "IMEI, SIM, número y actividad", appFeature = "Instalar o eliminar aplicaciones telefónicas", accountFeature = "Acceso a la cuenta y credenciales protegidas", messageFeature = "Revisar la actividad reciente de SMS", callFeature = "Revisar la actividad reciente de las llamadas", moderationFeature = "Resetear datos de acceso, número o dispositivo", tonesFeature = "Administrar tonos de llamada y notificación", auditFeature = "Revisar las acciones del administrador sensibles", configuratorFeature = "Administrar config.lua y media.lua a través de SQL" },
statistics = { eyebrow = "Datos en vivo", title = "Estadísticas del teléfono", body = "Uso actual y estado de los dispositivos, actualizado cada 15 segundos.", today = "Actividad de hoy", todayBody = "Actividad telefónica y administrativa desde medianoche.", messagesToday = "Mensajes", callsToday = "Llamadas", auditToday = "Acciones de administrador", coverage = "Cobertura de dispositivos", coverageBody = "Proporción actual de todos los teléfonos guardados.", linkedDevices = "Cuenta vinculada", simDevices = "SIM asignada", activeDevices = "Actualizado en 24 horas", ofDevices = "{count} de {total} dispositivos" },
configurator = { context = "Configuración de tiempo de ejecución", eyebrow = "Herramienta del sistema", sections = "Configuración", search = "Configuración de búsqueda o caminos", configScope = "config.lua", mediaScope = "media.lua", noResults = "Ninguna configuración correspondiente", loading = "Cargar la configuración de SQL...", title = "Configuración de teléfono", body = "Gestionar la configuración de teléfono y medios desde el espacio de trabajo de administrador protegido.", disabledTitle = "Configuración SQL no está activa", disabledBody = "Habilitar el configurador al principio de config.lua y reiniciar sky_phone. Hasta entonces, los valores del archivo permanecen activos y la edición está bloqueada.", manualSave = "Salvamiento manual", refreshNotice = "Nada se escribe automáticamente. La verificación verde verifica config.lua y media.lua en SQL y actualiza inmediatamente la configuración del servidor activo, el cliente, los medios y la interfaz de usuario.", fieldCount = "Campos {count}", secretConfigured = "Configuración secreta · introducir un reemplazo", invalidValue = "Comprueba el valor de la tabla o número resaltado.", saved = "Configuración SQL guardada y aplicada.", descriptions = { featureToggle = "Enciende o apaga {name}.", boolean = "Controla si {name} está permitido.", number = "Establece el valor numérico para {name}.", text = "Establece el valor de texto utilizado para {name}.", optionalText = "Establece el valor opcional para {name}; apague para desactivarlo.", list = "Gestiona todas las entradas utilizadas para {name}.", table = "Agrupa las configuraciones relacionadas para {name}.", credential = "Almacena la credencial protegida utilizada por {name}.", url = "Establece la URL o el punto final utilizado por {name}.", hosts = "Define qué dominios están permitidos para {name}.", milliseconds = "Establece el tiempo de {name} en milisegundos.", seconds = "Establece el tiempo de {name} en segundos.", rateLimit = "Limita la cantidad de acciones {name} permitidas por minuto.", byteLimit = "Establece el tamaño máximo de datos permitido para {name}.", textLimit = "Establece la longitud máxima de texto permitida para {name}.", distance = "Establece la distancia mundial utilizada para {name}.", coordinates = "Establece las coordenadas del mundo o la orientación para {name}.", gameAsset = "Establece el modelo GTA o el accesorio utilizado para {name}.", animation = "Establece el activo de animación utilizado para {name}.", access = "Define los puestos de trabajo, los grupos o el nivel de permiso para {name}.", integration = "Selecciona el marco o proveedor conectado para {name}.", path = "Establece la ruta de almacenamiento o recurso utilizada para {name}.", color = "Establece el color de interfaz utilizado para {name}.", displayText = "Establece el texto que se muestra a los jugadores para {name}.", phoneNumber = "Establece el número de teléfono o servicio utilizado para {name}.", routing = "Controla cómo se envía las solicitudes entrantes para {name}.", command = "Establece el comando de chat utilizado para abrir o ejecutar {name}.", locale = "Selecciona el idioma utilizado para {name}.", debug = "Controla el diagnóstico detallado de {name}.", mediaQuality = "Establece la calidad o volumen de los medios utilizados para {name}.", amount = "Establece la cantidad máxima o mostrada para {name}." }, table = { list = "Lista", table = "Mesa de llaves", vector = "Vector", entry = "Entrada", general = "Generales", addRow = "Añadir fila", addField = "Añadir campo", remove = "Eliminar", emptyList = "Aún no hay filas. Agregue la primera fila con más.", emptyTable = "Aún no hay campos. Añade la primera llave de abajo.", keyPlaceholder = "Nueva llave", convertToList = "Lista de uso", convertToMap = "Usa la tabla de teclas digitalizada", convertToTable = "Usa la tabla de teclas", types = { string = "El texto", number = "Número", boolean = "Cambiador", list = "Lista", table = "Cuadro" } } },
players = { eyebrow = "sesiones activas", title = "Jugadores en línea", online = "En línea ahora", empty = "No se encontraron jugadores", emptyBody = "Ajusta la búsqueda o actualiza la lista de jugadores en vivo." },
@@ -2178,3 +2178,46 @@ Locales["es"].Nui.AdminPanel.configurator.table.subtabs = {
AllowedJobs = "Trabajos permitidos",
Websites = "Las páginas web",
}
Locales["es"].Nui.AdminPanel.configurator.customTones = {
context = "Biblioteca de sonidos",
eyebrow = "Gestión de audio",
library = "Biblioteca",
title = "Tonos de llamada y notificación personalizados",
body = "Administra archivos de audio locales desde la base de datos o config.lua sin URL externas.",
configTitle = "Alternativa basada en archivos",
configBody = "Si el cliente de FiveM no abre el selector, coloca el archivo en la carpeta del recurso y regístralo en config.lua.",
configSource = "config.lua",
configManaged = "Este tono se administra mediante config.lua y aquí solo puede escucharse.",
name = "Nombre para mostrar",
namePlaceholder = "Por ejemplo Central",
category = "Usar como",
ringtone = "Tono de llamada",
notification = "Tono de notificación",
chooseFile = "Elegir archivo de audio",
fileHint = "MP3, OGG, WAV o WebM · máximo 2 MB y 30 segundos",
preview = "Escuchar",
add = "Añadir tono",
loading = "Cargando biblioteca de tonos...",
ringtones = "Tonos de llamada",
notifications = "Tonos de notificación",
empty = "Todavía no hay tonos personalizados en esta categoría.",
delete = "Eliminar tono",
confirmDelete = "Haz clic de nuevo para confirmar",
saved = "El tono se guardó y está disponible inmediatamente en todos los teléfonos.",
deleted = "El tono fue eliminado.",
errors = {
type = "Elige un archivo de audio MP3, OGG, WAV o WebM.",
size = "El archivo de audio no puede superar los 2 MB.",
duration = "El tono debe durar entre 0,25 y 30 segundos.",
invalid = "No se pudo leer el archivo de audio.",
playback = "No se pudo reproducir el tono.",
},
}
Locales["es"].Nui.AdminPanel.errors.invalid_tone = "Comprueba el nombre, tipo, tamaño y duración del tono."
Locales["es"].Nui.AdminPanel.errors.tone_name_taken = "Ya existe un tono con este nombre en esta categoría."
Locales["es"].Nui.AdminPanel.errors.tone_limit = "Esta categoría ya contiene 32 tonos personalizados."
Locales["es"].Nui.AdminPanel.errors.tone_not_found = "Ese tono ya no existe."
Locales["es"].Nui.AdminPanel.errors.invalid_upload = "La carga del tono está incompleta o no es válida."
Locales["es"].Nui.AdminPanel.errors.operation_in_progress = "Ya hay otra carga de tono en curso."
Locales["es"].Nui.AdminPanel.audit.actions.create_custom_tone = "Tono personalizado añadido"
Locales["es"].Nui.AdminPanel.audit.actions.delete_custom_tone = "Tono personalizado eliminado"
+2
View File
@@ -52,6 +52,7 @@ client_scripts {
'source/bridge/client/radio.lua',
'source/client/payphones.lua',
'source/client/custom_apps.lua',
'source/client/custom_tones.lua',
'source/client/nui_server_bridge.lua',
'source/client/nui_events.lua',
'source/client/notifications.lua',
@@ -105,6 +106,7 @@ server_scripts {
'source/server/phone.lua',
'source/server/device_directory.lua',
'source/server/db_migrate.lua',
'source/server/custom_tones.lua',
'source/server/admin.lua',
'source/server/lb_phone_migration.lua',
'source/server/custom_app_storage.lua',
+76
View File
@@ -0,0 +1,76 @@
local AUDIO_TRANSFER_TIMEOUT_MS = math.max(15000, tonumber(Config.Bridge.CallbackTimeout) or 0)
local MAX_AUDIO_REQUEST_ID = 2147483646
local MAX_PENDING_AUDIO_REQUESTS = 2
local next_audio_request_id = 0
local pending_audio_requests = {}
local pending_audio_request_count = 0
RegisterNetEvent("sky_phone:tones:audio-response", function(request_id, result)
local request = pending_audio_requests[request_id]
if not request then
return
end
pending_audio_requests[request_id] = nil
pending_audio_request_count = pending_audio_request_count - 1
if type(result) == "table" then
request:resolve(result)
return
end
request:resolve({ success = false, error = "request_failed" })
end)
RegisterNUICallback("tones:audio", function(data, cb)
local tone_id = type(data) == "table" and data.id or nil
if type(tone_id) ~= "string" or tone_id == "" or #tone_id > 128 then
cb({ success = false, error = "invalid_request" })
return
end
if pending_audio_request_count >= MAX_PENDING_AUDIO_REQUESTS then
cb({ success = false, error = "request_in_progress" })
return
end
next_audio_request_id = next_audio_request_id % MAX_AUDIO_REQUEST_ID + 1
local request_id = next_audio_request_id
local request = promise.new()
pending_audio_requests[request_id] = request
pending_audio_request_count = pending_audio_request_count + 1
TriggerServerEvent("sky_phone:tones:audio-request", request_id, tone_id)
SetTimeout(AUDIO_TRANSFER_TIMEOUT_MS, function()
if pending_audio_requests[request_id] ~= request then
return
end
pending_audio_requests[request_id] = nil
pending_audio_request_count = pending_audio_request_count - 1
Bridge.Debug(
"error",
"[sky_phone] Custom tone audio request '%s' timed out.",
tostring(tone_id)
)
request:resolve({ success = false, error = "request_timeout" })
end)
local result = Citizen.Await(request)
if type(result) == "table" then
cb(result)
return
end
cb({ success = false, error = "request_failed" })
end)
AddEventHandler("onResourceStop", function(resource_name)
if resource_name ~= GetCurrentResourceName() then
return
end
for request_id, request in pairs(pending_audio_requests) do
pending_audio_requests[request_id] = nil
request:resolve({ success = false, error = "resource_stopped" })
end
pending_audio_request_count = 0
end)
+18
View File
@@ -99,6 +99,22 @@ local function send_admin_panel_open()
})
end
local function send_phone_tone_catalog()
local response = Bridge.Callbacks.Trigger("sky_phone:tones:list", {})
if not response or not response.success then
Bridge.Debug("warn", "[sky_phone] Could not load the custom tone catalog.")
return
end
SendNUIMessage({
type = "phone:tones",
data = response.data,
})
end
RegisterNetEvent("sky_phone:tones:changed", function()
send_phone_tone_catalog()
end)
local function close_admin_panel()
if not admin_panel_open then
return
@@ -147,6 +163,7 @@ AddEventHandler("sky_phone:configurator:updated", function()
refresh_phone_key_mapping()
refresh_test_data_command_suggestion()
refresh_admin_command_suggestion()
send_phone_tone_catalog()
SkyPhoneApps.SendCatalog()
if is_open and device_payload then
apply_disabled_apps(device_payload)
@@ -378,6 +395,7 @@ RegisterNUICallback("ui:ready", function(data, cb)
-- cannot survive unless their UI is replayed as part of this handshake.
SkyPhoneFocus.BeginNuiHydration()
Bridge.Debug("debug", "[sky_phone] NUI reported ready.", { always = true })
send_phone_tone_catalog()
SkyPhoneApps.SendCatalog()
if open_requested and device_payload then
send_open_message()
@@ -3,6 +3,7 @@ local callback_groups = {
admin = [[
bootstrap player save-apps reveal-password activity
reset-passcode change-number factory-reset configurator save-configurator
tones tone-upload-start tone-upload-chunk tone-upload-finish tone-upload-cancel delete-tone
]],
banking = [[overview transfer]],
billing = [[overview list detail markRead pay dispute]],
@@ -71,6 +72,7 @@ local callback_groups = {
]],
security = [[unlock set-passcode change-passcode disable-passcode]],
sim = [[insert eject]],
tones = [[list]],
["weazel-news"] = [[context list get manage-list create update delete]],
}
+110
View File
@@ -950,4 +950,114 @@ Bridge.Callbacks.Register("sky_phone:admin:save-configurator", function(source,
end
return response
end)
Bridge.Callbacks.Register("sky_phone:admin:tones", function(source)
local authorized, error_response = require_admin(
source,
"tones_read",
Config.AdminPanel.ReadRequestsPerMinute
)
if not authorized then
return error_response
end
return { success = true, data = SkyPhoneTones.GetAdminList() }
end)
Bridge.Callbacks.Register("sky_phone:admin:tone-upload-start", function(source, data)
local authorized, error_response = require_admin(
source,
"tone_upload_start",
Config.AdminPanel.ActionRequestsPerMinute
)
if not authorized then
return error_response
end
local actor_identifier = Bridge.Framework.GetIdentifier(source)
if not actor_identifier then
return { success = false, error = "player_unavailable" }
end
local actor_name = player_name(source)
return SkyPhoneTones.BeginUpload(source, data, actor_identifier, actor_name)
end)
Bridge.Callbacks.Register("sky_phone:admin:tone-upload-chunk", function(source, data)
local authorized, error_response = require_admin(
source,
"tone_upload_chunk",
math.max(400, tonumber(Config.AdminPanel.ActionRequestsPerMinute) or 0)
)
if not authorized then
return error_response
end
return SkyPhoneTones.AppendUploadChunk(source, data)
end)
Bridge.Callbacks.Register("sky_phone:admin:tone-upload-finish", function(source, data)
local authorized, error_response = require_admin(
source,
"tone_upload_finish",
Config.AdminPanel.ActionRequestsPerMinute
)
if not authorized then
return error_response
end
local actor_identifier = Bridge.Framework.GetIdentifier(source)
if not actor_identifier then
return { success = false, error = "player_unavailable" }
end
local response = SkyPhoneTones.CompleteUpload(source, data)
if response.success then
local upload = response.upload or {}
write_audit(source, source, actor_identifier, nil, "create_custom_tone", {
durationMs = tonumber(upload.durationMs),
label = trim(upload.label),
toneId = response.toneId,
toneType = upload.toneType,
})
end
response.upload = nil
return response
end)
Bridge.Callbacks.Register("sky_phone:admin:tone-upload-cancel", function(source, data)
local authorized, error_response = require_admin(
source,
"tone_upload_cancel",
Config.AdminPanel.ActionRequestsPerMinute
)
if not authorized then
return error_response
end
return SkyPhoneTones.CancelUpload(source, data)
end)
Bridge.Callbacks.Register("sky_phone:admin:delete-tone", function(source, data)
local authorized, error_response = require_admin(
source,
"tone_delete",
Config.AdminPanel.ActionRequestsPerMinute
)
if not authorized then
return error_response
end
if type(data) ~= "table" then
return { success = false, error = "invalid_request" }
end
local actor_identifier = Bridge.Framework.GetIdentifier(source)
if not actor_identifier then
return { success = false, error = "player_unavailable" }
end
local response = SkyPhoneTones.Delete(data.id)
if response.success then
write_audit(source, source, actor_identifier, nil, "delete_custom_tone", {
label = response.tone.label,
toneId = response.tone.id,
toneType = response.tone.tone_type,
})
end
return response
end)
end)
+698
View File
@@ -0,0 +1,698 @@
SkyPhoneTones = SkyPhoneTones or {}
local MAX_TONES_PER_TYPE = 32
local MAX_AUDIO_BYTES = 2000000
local MAX_DURATION_MS = 30000
local MAX_PAYLOAD_CHARS = 2666668
local MAX_LABEL_LENGTH = 64
local MAX_TRANSFER_CHUNK_CHARS = 8000
local MAX_TRANSFER_CHUNKS = 334
local TRANSFER_TIMEOUT_MS = 120000
local AUDIO_TRANSFER_BYTES_PER_SECOND = 3000000
local MAX_AUDIO_REQUEST_ID = 2147483646
local PUBLIC_READS_PER_MINUTE = 90
local PUBLIC_AUDIO_READS_PER_MINUTE = 24
local pending_uploads = {}
local configured_rows = {}
local configured_audio = {}
local allowed_mime_types = {
["audio/mpeg"] = true,
["audio/ogg"] = true,
["audio/wav"] = true,
["audio/webm"] = true,
}
local mime_types_by_extension = {
mp3 = "audio/mpeg",
ogg = "audio/ogg",
wav = "audio/wav",
webm = "audio/webm",
}
local base64_alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
local function affected_rows(result)
if type(result) == "number" then
return result
end
return type(result) == "table" and tonumber(result.affectedRows) or 0
end
local function trim(value)
if type(value) ~= "string" then
return ""
end
return value:match("^%s*(.-)%s*$")
end
local function valid_uuid(value)
return type(value) == "string"
and value:match("^[0-9a-fA-F]+%-[0-9a-fA-F]+%-[0-9a-fA-F]+%-[0-9a-fA-F]+%-[0-9a-fA-F]+$") ~= nil
and #value == 36
end
local function decoded_base64_size(payload)
if type(payload) ~= "string"
or payload == ""
or #payload > MAX_PAYLOAD_CHARS
or #payload % 4 ~= 0
then
return nil
end
local body, padding = payload:match("^([A-Za-z0-9+/]*)(=*)$")
if not body or #padding > 2 then
return nil
end
return math.floor(#payload * 3 / 4) - #padding
end
local function decode_base64_prefix(payload, max_bytes)
local decoded = {}
local output_length = 0
for index = 1, #payload, 4 do
local first = base64_alphabet:find(payload:sub(index, index), 1, true)
local second = base64_alphabet:find(payload:sub(index + 1, index + 1), 1, true)
local third_character = payload:sub(index + 2, index + 2)
local fourth_character = payload:sub(index + 3, index + 3)
local third = third_character == "=" and 1
or base64_alphabet:find(third_character, 1, true)
local fourth = fourth_character == "=" and 1
or base64_alphabet:find(fourth_character, 1, true)
if not first or not second or not third or not fourth then
return nil
end
local combined = (first - 1) * 262144
+ (second - 1) * 4096
+ (third - 1) * 64
+ (fourth - 1)
decoded[#decoded + 1] = string.char(math.floor(combined / 65536) % 256)
output_length = output_length + 1
if third_character ~= "=" and output_length < max_bytes then
decoded[#decoded + 1] = string.char(math.floor(combined / 256) % 256)
output_length = output_length + 1
end
if fourth_character ~= "=" and output_length < max_bytes then
decoded[#decoded + 1] = string.char(combined % 256)
output_length = output_length + 1
end
if output_length >= max_bytes then
break
end
end
return table.concat(decoded)
end
local function valid_audio_signature(mime_type, raw)
if type(raw) ~= "string" then
return false
end
if mime_type == "audio/mpeg" then
local first, second = raw:byte(1, 2)
return raw:sub(1, 3) == "ID3"
or (first == 0xFF and second ~= nil and (second & 0xE0) == 0xE0)
end
if mime_type == "audio/ogg" then
return raw:sub(1, 4) == "OggS"
end
if mime_type == "audio/wav" then
return raw:sub(1, 4) == "RIFF" and raw:sub(9, 12) == "WAVE"
end
if mime_type == "audio/webm" then
return raw:sub(1, 4) == string.char(0x1A, 0x45, 0xDF, 0xA3)
end
return false
end
local function encode_base64(value)
local encoded = {}
local output_index = 1
for index = 1, #value, 3 do
local first, second, third = value:byte(index, index + 2)
second = second or 0
third = third or 0
local combined = first * 65536 + second * 256 + third
local first_index = math.floor(combined / 262144) % 64 + 1
local second_index = math.floor(combined / 4096) % 64 + 1
local third_index = math.floor(combined / 64) % 64 + 1
local fourth_index = combined % 64 + 1
encoded[output_index] = base64_alphabet:sub(first_index, first_index)
encoded[output_index + 1] = base64_alphabet:sub(second_index, second_index)
encoded[output_index + 2] = index + 1 <= #value
and base64_alphabet:sub(third_index, third_index)
or "="
encoded[output_index + 3] = index + 2 <= #value
and base64_alphabet:sub(fourth_index, fourth_index)
or "="
output_index = output_index + 4
end
return table.concat(encoded)
end
local function normalize_create_payload(data)
if type(data) ~= "table" then
return nil
end
local tone_type = data.toneType
local label = trim(data.label)
local mime_type = type(data.mimeType) == "string" and data.mimeType:lower() or ""
local duration_ms = tonumber(data.durationMs)
local byte_size = decoded_base64_size(data.payload)
local prefix = byte_size and decode_base64_prefix(data.payload, 16) or nil
if (tone_type ~= "ringtone" and tone_type ~= "notification")
or label == ""
or #label > MAX_LABEL_LENGTH
or label:find("[%c]")
or not allowed_mime_types[mime_type]
or not byte_size
or not valid_audio_signature(mime_type, prefix)
or byte_size < 1
or byte_size > MAX_AUDIO_BYTES
or not duration_ms
or duration_ms ~= math.floor(duration_ms)
or duration_ms < 250
or duration_ms > MAX_DURATION_MS
then
return nil
end
return {
audioPayload = data.payload,
byteSize = byte_size,
durationMs = duration_ms,
label = label,
mimeType = mime_type,
toneType = tone_type,
}
end
local function map_tone(row, include_admin_fields)
local tone = {
byteSize = tonumber(row.byte_size) or 0,
createdAt = row.created_at,
durationMs = tonumber(row.duration_ms) or 0,
id = row.id,
label = row.label,
mimeType = row.mime_type,
source = row.source == "config" and "config" or "database",
toneType = row.tone_type,
}
if include_admin_fields then
tone.createdBy = row.created_by_name
end
return tone
end
local function load_database_rows()
return Bridge.Database.Query([[
SELECT
`id`, `tone_type`, `label`, `mime_type`, `byte_size`, `duration_ms`,
`created_by_name`, `created_at`
FROM `sky_phone_custom_tones`
ORDER BY `tone_type` ASC, `label` ASC, `id` ASC
]], {})
end
local function load_rows()
local rows = {}
for _, row in ipairs(configured_rows) do
rows[#rows + 1] = row
end
for _, row in ipairs(load_database_rows()) do
row.source = "database"
rows[#rows + 1] = row
end
table.sort(rows, function(left, right)
if left.tone_type ~= right.tone_type then
return left.tone_type < right.tone_type
end
if left.label ~= right.label then
return left.label < right.label
end
return left.id < right.id
end)
return rows
end
function SkyPhoneTones.GetAdminList()
local tones = {}
for _, row in ipairs(load_rows()) do
tones[#tones + 1] = map_tone(row, true)
end
return tones
end
function SkyPhoneTones.GetCatalog()
local catalog = {
notificationSounds = {},
ringtones = {},
}
local counts = { notification = 0, ringtone = 0 }
for _, row in ipairs(load_rows()) do
if counts[row.tone_type] < MAX_TONES_PER_TYPE then
counts[row.tone_type] = counts[row.tone_type] + 1
local target = row.tone_type == "ringtone" and catalog.ringtones or catalog.notificationSounds
target[#target + 1] = map_tone(row, false)
end
end
return catalog
end
function SkyPhoneTones.GetAudio(id)
local configured = type(id) == "string" and configured_audio[id] or nil
if configured then
local raw = LoadResourceFile(GetCurrentResourceName(), configured.file)
if type(raw) ~= "string"
or #raw < 1
or #raw > MAX_AUDIO_BYTES
or not valid_audio_signature(configured.mime_type, raw:sub(1, 16))
then
Bridge.Debug(
"error",
"[sky_phone] Configured custom tone '%s' could not be read safely.",
tostring(id),
{ always = true }
)
return nil
end
return {
id = id,
mimeType = configured.mime_type,
payload = encode_base64(raw),
}
end
if not valid_uuid(id) then
return nil
end
local rows = Bridge.Database.Query([[
SELECT `id`, `mime_type`, `audio_payload`
FROM `sky_phone_custom_tones`
WHERE `id` = ?
LIMIT 1
]], { id })
local row = rows[1]
if not row then
return nil
end
return {
id = row.id,
mimeType = row.mime_type,
payload = row.audio_payload,
}
end
local function configured_type_count(tone_type)
local count = 0
for _, row in ipairs(configured_rows) do
if row.tone_type == tone_type then
count = count + 1
end
end
return count
end
local function configured_label_exists(tone_type, label)
for _, row in ipairs(configured_rows) do
if row.tone_type == tone_type and row.label == label then
return true
end
end
return false
end
function SkyPhoneTones.Create(data, actor_identifier, actor_name)
local tone = normalize_create_payload(data)
if not tone or type(actor_identifier) ~= "string" or type(actor_name) ~= "string" then
return { success = false, error = "invalid_tone" }
end
if configured_label_exists(tone.toneType, tone.label) then
return { success = false, error = "tone_name_taken" }
end
local duplicate = Bridge.Database.Query([[
SELECT 1
FROM `sky_phone_custom_tones`
WHERE `tone_type` = ? AND `label` = ?
LIMIT 1
]], { tone.toneType, tone.label })
if duplicate[1] then
return { success = false, error = "tone_name_taken" }
end
local count_rows = Bridge.Database.Query([[
SELECT COUNT(*) AS `count`
FROM `sky_phone_custom_tones`
WHERE `tone_type` = ?
]], { tone.toneType })
if (tonumber(count_rows[1] and count_rows[1].count) or 0) + configured_type_count(tone.toneType)
>= MAX_TONES_PER_TYPE
then
return { success = false, error = "tone_limit" }
end
local ids = Bridge.Database.Query("SELECT UUID() AS `id`", {})
local id = ids[1] and ids[1].id
if not valid_uuid(id) then
error("[sky_phone] Database did not generate a custom tone UUID.")
end
local result = Bridge.Database.Query([[
INSERT INTO `sky_phone_custom_tones` (
`id`, `tone_type`, `label`, `mime_type`, `audio_payload`, `byte_size`,
`duration_ms`, `created_by_identifier`, `created_by_name`
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
]], {
id,
tone.toneType,
tone.label,
tone.mimeType,
tone.audioPayload,
tone.byteSize,
tone.durationMs,
actor_identifier,
actor_name,
})
if affected_rows(result) ~= 1 then
return { success = false, error = "request_failed" }
end
TriggerClientEvent("sky_phone:tones:changed", -1)
return { success = true, data = SkyPhoneTones.GetAdminList(), toneId = id }
end
local function normalize_upload_metadata(data)
if type(data) ~= "table" then
return nil
end
local tone_type = data.toneType
local label = trim(data.label)
local mime_type = type(data.mimeType) == "string" and data.mimeType:lower() or ""
local duration_ms = tonumber(data.durationMs)
local payload_length = tonumber(data.payloadLength)
if (tone_type ~= "ringtone" and tone_type ~= "notification")
or label == ""
or #label > MAX_LABEL_LENGTH
or label:find("[%c]")
or not allowed_mime_types[mime_type]
or not duration_ms
or duration_ms ~= math.floor(duration_ms)
or duration_ms < 250
or duration_ms > MAX_DURATION_MS
or not payload_length
or payload_length ~= math.floor(payload_length)
or payload_length < 4
or payload_length > MAX_PAYLOAD_CHARS
or payload_length % 4 ~= 0
then
return nil
end
return {
durationMs = duration_ms,
label = label,
mimeType = mime_type,
payloadLength = payload_length,
toneType = tone_type,
}
end
function SkyPhoneTones.BeginUpload(source, data, actor_identifier, actor_name)
local metadata = normalize_upload_metadata(data)
if type(source) ~= "number"
or not metadata
or type(actor_identifier) ~= "string"
or type(actor_name) ~= "string"
then
return { success = false, error = "invalid_tone" }
end
pending_uploads[source] = nil
if configured_label_exists(metadata.toneType, metadata.label) then
return { success = false, error = "tone_name_taken" }
end
local ids = Bridge.Database.Query("SELECT UUID() AS `id`", {})
local upload_id = ids[1] and ids[1].id
if not valid_uuid(upload_id) then
error("[sky_phone] Database did not generate a custom tone upload UUID.")
end
local state = {
actor_identifier = actor_identifier,
actor_name = actor_name,
chunks = {},
length = 0,
metadata = metadata,
next_index = 1,
upload_id = upload_id,
}
pending_uploads[source] = state
SetTimeout(TRANSFER_TIMEOUT_MS, function()
if pending_uploads[source] == state then
pending_uploads[source] = nil
end
end)
return { success = true, data = { uploadId = upload_id } }
end
function SkyPhoneTones.AppendUploadChunk(source, data)
local state = pending_uploads[source]
local chunk = type(data) == "table" and data.chunk or nil
local index = type(data) == "table" and tonumber(data.index) or nil
if not state
or type(data) ~= "table"
or data.uploadId ~= state.upload_id
or not index
or index ~= math.floor(index)
or index ~= state.next_index
or index > MAX_TRANSFER_CHUNKS
or type(chunk) ~= "string"
or #chunk < 1
or #chunk > MAX_TRANSFER_CHUNK_CHARS
or not chunk:match("^[A-Za-z0-9+/=]+$")
or state.length + #chunk > state.metadata.payloadLength
then
return { success = false, error = "invalid_upload" }
end
state.chunks[index] = chunk
state.length = state.length + #chunk
state.next_index = index + 1
return { success = true }
end
function SkyPhoneTones.CompleteUpload(source, data)
local state = pending_uploads[source]
if not state
or type(data) ~= "table"
or data.uploadId ~= state.upload_id
or state.length ~= state.metadata.payloadLength
then
return { success = false, error = "invalid_upload" }
end
pending_uploads[source] = nil
local payload = table.concat(state.chunks)
local response = SkyPhoneTones.Create({
durationMs = state.metadata.durationMs,
label = state.metadata.label,
mimeType = state.metadata.mimeType,
payload = payload,
toneType = state.metadata.toneType,
}, state.actor_identifier, state.actor_name)
if response.success then
response.upload = state.metadata
end
return response
end
function SkyPhoneTones.CancelUpload(source, data)
local state = pending_uploads[source]
if state and type(data) == "table" and data.uploadId == state.upload_id then
pending_uploads[source] = nil
end
return { success = true }
end
function SkyPhoneTones.Delete(id)
if not valid_uuid(id) then
return { success = false, error = "invalid_request" }
end
local rows = Bridge.Database.Query([[
SELECT `id`, `tone_type`, `label`
FROM `sky_phone_custom_tones`
WHERE `id` = ?
LIMIT 1
]], { id })
local tone = rows[1]
if not tone then
return { success = false, error = "tone_not_found" }
end
local result = Bridge.Database.Query(
"DELETE FROM `sky_phone_custom_tones` WHERE `id` = ?",
{ id }
)
if affected_rows(result) ~= 1 then
return { success = false, error = "tone_not_found" }
end
TriggerClientEvent("sky_phone:tones:changed", -1)
return { success = true, data = SkyPhoneTones.GetAdminList(), tone = tone }
end
local function load_configured_tones()
local groups = {
{ entries = Config.CustomTones and Config.CustomTones.Ringtones, name = "Ringtones", tone_type = "ringtone" },
{
entries = Config.CustomTones and Config.CustomTones.NotificationSounds,
name = "NotificationSounds",
tone_type = "notification",
},
}
local seen_ids = {}
local seen_labels = {}
for _, group in ipairs(groups) do
local entries = type(group.entries) == "table" and group.entries or {}
for index, entry in ipairs(entries) do
local id = type(entry) == "table" and trim(entry.Id):lower() or ""
local label = type(entry) == "table" and trim(entry.Label) or ""
local file = type(entry) == "table" and trim(entry.File):gsub("\\", "/") or ""
local duration_ms = type(entry) == "table" and tonumber(entry.DurationMs) or nil
local extension = file:lower():match("%.([a-z0-9]+)$")
local mime_type = extension and mime_types_by_extension[extension] or nil
local row_id = ("config:%s:%s"):format(group.tone_type, id)
local label_key = group.tone_type .. ":" .. label
local invalid_reason
if type(entry) ~= "table" then
invalid_reason = "entry must be a table"
elseif not id:match("^[a-z0-9][a-z0-9_-]*$") or #id > 48 then
invalid_reason = "Id must contain 1-48 lowercase letters, numbers, underscores, or hyphens"
elseif label == "" or #label > MAX_LABEL_LENGTH or label:find("[%c]") then
invalid_reason = "Label must contain 1-64 visible characters"
elseif file:sub(1, 20) ~= "config/custom_tones/" or file:find("..", 1, true) then
invalid_reason = "File must stay inside config/custom_tones"
elseif not mime_type then
invalid_reason = "File must use mp3, ogg, wav, or webm"
elseif not duration_ms
or duration_ms ~= math.floor(duration_ms)
or duration_ms < 250
or duration_ms > MAX_DURATION_MS
then
invalid_reason = "DurationMs must be an integer between 250 and 30000"
elseif seen_ids[row_id] then
invalid_reason = "Id is duplicated"
elseif seen_labels[label_key] then
invalid_reason = "Label is duplicated in this category"
elseif configured_type_count(group.tone_type) >= MAX_TONES_PER_TYPE then
invalid_reason = "the category contains more than 32 configured tones"
end
local raw
if not invalid_reason then
raw = LoadResourceFile(GetCurrentResourceName(), file)
if type(raw) ~= "string" or #raw < 1 then
invalid_reason = "File could not be read"
elseif #raw > MAX_AUDIO_BYTES then
invalid_reason = "File exceeds 2 MB"
elseif not valid_audio_signature(mime_type, raw:sub(1, 16)) then
invalid_reason = "File content does not match its audio extension"
end
end
if invalid_reason then
Bridge.Debug(
"error",
"[sky_phone] Ignored Config.CustomTones.%s[%s]: %s.",
group.name,
tostring(index),
invalid_reason,
{ always = true }
)
else
seen_ids[row_id] = true
seen_labels[label_key] = true
configured_rows[#configured_rows + 1] = {
byte_size = #raw,
created_at = "",
created_by_name = "config.lua",
duration_ms = duration_ms,
id = row_id,
label = label,
mime_type = mime_type,
source = "config",
tone_type = group.tone_type,
}
configured_audio[row_id] = { file = file, mime_type = mime_type }
end
end
end
end
load_configured_tones()
AddEventHandler("playerDropped", function()
pending_uploads[source] = nil
end)
Bridge.Database.AfterMigration("sky_phone", function()
Bridge.Callbacks.Register("sky_phone:tones:list", function(source)
if not SkyPhone.AllowOperation(source, "custom_tones_list", PUBLIC_READS_PER_MINUTE, 60) then
return { success = false, error = "rate_limited" }
end
return { success = true, data = SkyPhoneTones.GetCatalog() }
end)
RegisterNetEvent("sky_phone:tones:audio-request", function(request_id, tone_id)
local player_source = source
if type(player_source) ~= "number"
or player_source < 1
or type(request_id) ~= "number"
or request_id ~= math.floor(request_id)
or request_id < 1
or request_id > MAX_AUDIO_REQUEST_ID
or type(tone_id) ~= "string"
or tone_id == ""
or #tone_id > 128
then
return
end
if not SkyPhone.AllowOperation(
player_source,
"custom_tones_audio",
PUBLIC_AUDIO_READS_PER_MINUTE,
60
) then
TriggerClientEvent(
"sky_phone:tones:audio-response",
player_source,
request_id,
{ success = false, error = "rate_limited" }
)
return
end
local audio = SkyPhoneTones.GetAudio(tone_id)
local audio_size = audio and decoded_base64_size(audio.payload) or nil
local signature = audio_size and decode_base64_prefix(audio.payload, 16) or nil
if not audio
or audio.id ~= tone_id
or not allowed_mime_types[audio.mimeType]
or not audio_size
or audio_size < 1
or audio_size > MAX_AUDIO_BYTES
or not valid_audio_signature(audio.mimeType, signature)
then
TriggerClientEvent(
"sky_phone:tones:audio-response",
player_source,
request_id,
{ success = false, error = "tone_not_found" }
)
return
end
TriggerLatentClientEvent(
"sky_phone:tones:audio-response",
player_source,
AUDIO_TRANSFER_BYTES_PER_SECOND,
request_id,
{ success = true, data = audio }
)
end)
end)
+23
View File
@@ -456,6 +456,29 @@ local schema = {
},
tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
},
{
name = "sky_phone_custom_tones",
columns = {
{ name = "id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
{ name = "tone_type", type = "ENUM('ringtone','notification') NOT NULL" },
{ name = "label", type = "VARCHAR(64) NOT NULL" },
{ name = "mime_type", type = "VARCHAR(40) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
{ name = "audio_payload", type = "MEDIUMTEXT NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
{ name = "byte_size", type = "INT UNSIGNED NOT NULL" },
{ name = "duration_ms", type = "INT UNSIGNED NOT NULL" },
{ name = "created_by_identifier", type = "VARCHAR(80) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
{ name = "created_by_name", type = "VARCHAR(120) NOT NULL" },
{ name = "created_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP" },
},
primaryKey = "id",
uniqueKeys = {
{ name = "uniq_sky_phone_custom_tone_label", columns = "(`tone_type`, `label`)" },
},
indexes = {
{ name = "idx_sky_phone_custom_tones_type", columns = "(`tone_type`, `created_at`, `id`)" },
},
tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
},
{
name = "sky_phone_notes",
columns = {
@@ -20,6 +20,7 @@ local is_sequence
local FIXED_CONFIG_PATHS = {
CommandPermissions = true,
["AdminPanel.AdminGroups"] = true,
CustomTones = true,
["TestData.AdminGroups"] = true,
["FlipTok.AdminGroups"] = true,
["Picstagram.AdminGroups"] = true,
@@ -34,7 +35,7 @@ if configurator_enabled then
^1 The Phone Configurator is ENABLED.^0
^1 Runtime settings from config.lua and media.lua are DISABLED.^0
^1 Configure all phone and media settings IN GAME through /phonepanel.^0
^1 Only Config.PhoneConfigurator.Enabled and Config.CommandPermissions remain file-based.^0
^1 Config.PhoneConfigurator, Config.CommandPermissions and Config.CustomTones remain file-based.^0
^1%s^0]]):format(border, border, border))
end
@@ -385,7 +386,7 @@ local function apply_runtime_configuration()
local runtime_config = deserialize_value(stored_config)
for key, value in pairs(runtime_config) do
if key ~= "CommandPermissions" then
if key ~= "CommandPermissions" and key ~= "CustomTones" then
if type(Config[key]) == "table" and type(value) == "table" then
apply_runtime_table(Config[key], value)
else
@@ -1367,7 +1368,11 @@ end
default_config = {}
for key, value in pairs(ConfigDefaults) do
if key ~= "Media" and key ~= "PhoneConfigurator" and key ~= "CommandPermissions" then
if key ~= "Media"
and key ~= "PhoneConfigurator"
and key ~= "CommandPermissions"
and key ~= "CustomTones"
then
default_config[key] = serialize_value(value)
end
end
+16
View File
@@ -200,6 +200,22 @@ CREATE TABLE IF NOT EXISTS `sky_phone_admin_audit` (
KEY `idx_sky_phone_admin_audit_target` (`target_identifier`, `created_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `sky_phone_custom_tones` (
`id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
`tone_type` ENUM('ringtone','notification') NOT NULL,
`label` VARCHAR(64) NOT NULL,
`mime_type` VARCHAR(40) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
`audio_payload` MEDIUMTEXT CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
`byte_size` INT UNSIGNED NOT NULL,
`duration_ms` INT UNSIGNED NOT NULL,
`created_by_identifier` VARCHAR(80) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
`created_by_name` VARCHAR(120) NOT NULL,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uniq_sky_phone_custom_tone_label` (`tone_type`, `label`),
KEY `idx_sky_phone_custom_tones_type` (`tone_type`, `created_at`, `id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `sky_phone_configurator` (
`id` TINYINT UNSIGNED NOT NULL,
`config_payload` LONGTEXT NOT NULL,