mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-09-01 10:48:55 +00:00
Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bbc2cbe000 | |||
| 8efaa9f03b | |||
| efcc67334f | |||
| 693c9bd6f9 | |||
| a597e161ea | |||
| b0b8cfc550 | |||
| 76484cfe05 | |||
| b406fa9443 | |||
| 2388ba9b4e | |||
| c6394fda66 | |||
| 4516469c33 |
@@ -89,7 +89,7 @@ Sky Phone is built to be the **free FiveM phone you can choose without accepting
|
|||||||
| **Inventories** | ak47_inventory, codem-inventory, core_inventory, jaksam_inventory, jpr-inventory, lj-inventory, mf-inventory, one_inventory, origen_inventory, ox_inventory, ps-inventory, qb-inventory, qs-inventory, smx-inventory, tgiann-inventory, hex_4_inventory, and native ESX inventory |
|
| **Inventories** | ak47_inventory, codem-inventory, core_inventory, jaksam_inventory, jpr-inventory, lj-inventory, mf-inventory, one_inventory, origen_inventory, ox_inventory, ps-inventory, qb-inventory, qs-inventory, smx-inventory, tgiann-inventory, hex_4_inventory, and native ESX inventory |
|
||||||
| **Calls** | YACA, PMA Voice, SaltyChat |
|
| **Calls** | YACA, PMA Voice, SaltyChat |
|
||||||
| **Radio** | YACA, PMA Voice, SaltyChat |
|
| **Radio** | YACA, PMA Voice, SaltyChat |
|
||||||
| **Housing** | RTX Housing, Quasar Housing, VMS Housing, RX Housing, NoLag Properties, SN Properties, ESX Property, qbx_properties |
|
| **Housing** | RTX Housing, Quasar Housing, TGIANN House, VMS Housing, RX Housing, NoLag Properties, SN Properties, ESX Property, qbx_properties |
|
||||||
| **Garages** | Built-in/custom data and a broad set of popular garage providers configured through the bridge |
|
| **Garages** | Built-in/custom data and a broad set of popular garage providers configured through the bridge |
|
||||||
| **Custom app contracts** | Sky Phone, LB Phone, 17Movement, High Phone, Quasar Smartphone, YSeries |
|
| **Custom app contracts** | Sky Phone, LB Phone, 17Movement, High Phone, Quasar Smartphone, YSeries |
|
||||||
| **Languages** | English, German |
|
| **Languages** | English, German |
|
||||||
@@ -650,7 +650,7 @@ Select the provider under `Config.Garage.System`. Vehicle images use the configu
|
|||||||
|
|
||||||
### Housing
|
### Housing
|
||||||
|
|
||||||
Select `rtx`, `quasar`, `vms`, `rx`, `nolag`, `sn`, `esx_property`, or `qbx_properties` under `Config.Housing.System`. Automatic mode uses `Config.Housing.AutoPriority` and keeps the existing `esx_property` and `qbx_properties` defaults ahead of newly supported providers. Select a provider explicitly when multiple housing resources are running. Each bridge exposes only the capabilities supported by the documented provider API.
|
Select `rtx`, `quasar`, `tgiann`, `vms`, `rx`, `nolag`, `sn`, `esx_property`, or `qbx_properties` under `Config.Housing.System`. Automatic mode uses `Config.Housing.AutoPriority` and keeps the existing `esx_property` and `qbx_properties` defaults ahead of newly supported providers. Select a provider explicitly when multiple housing resources are running. Each bridge exposes only the capabilities supported by the documented provider API. The TGIANN bridge expects `tgiann-core` to start before `tgiann-house`, lists server-authorized owner properties, and supports entrance waypoints; TGIANN does not publish stable external contracts for keyholders, lock controls, CCTV, or live garage status.
|
||||||
|
|
||||||
### Companies
|
### Companies
|
||||||
|
|
||||||
|
|||||||
@@ -86,6 +86,8 @@ import { parsePhonePreferences } from '@/utils/preferences'
|
|||||||
import { getHairlinePixelStyle } from '@/utils/rendering'
|
import { getHairlinePixelStyle } from '@/utils/rendering'
|
||||||
import { isTextInputElement } from '@/utils/textInputFocus'
|
import { isTextInputElement } from '@/utils/textInputFocus'
|
||||||
import { configurePhoneNumberFormat } from '@/utils/phone'
|
import { configurePhoneNumberFormat } from '@/utils/phone'
|
||||||
|
import { consumeEscape } from '@/utils/keyboard'
|
||||||
|
import type { CustomPhoneToneCatalog } from '@/utils/customTones'
|
||||||
import { isTrustedRootMessageSource } from '@/utils/windowMessages'
|
import { isTrustedRootMessageSource } from '@/utils/windowMessages'
|
||||||
import SpringboardView from '@/views/SpringboardView.vue'
|
import SpringboardView from '@/views/SpringboardView.vue'
|
||||||
|
|
||||||
@@ -117,6 +119,7 @@ type AppMessage = {
|
|||||||
| CustomAppEventData
|
| CustomAppEventData
|
||||||
| NavigationEventData
|
| NavigationEventData
|
||||||
| AdminPanelOpenPayload
|
| AdminPanelOpenPayload
|
||||||
|
| CustomPhoneToneCatalog
|
||||||
}
|
}
|
||||||
|
|
||||||
type AdminPanelOpenPayload = Required<
|
type AdminPanelOpenPayload = Required<
|
||||||
@@ -749,6 +752,8 @@ function onMessage(event: MessageEvent<AppMessage>): void {
|
|||||||
adminPanelOpen.value = true
|
adminPanelOpen.value = true
|
||||||
} else if (event.data?.type === 'admin:close') {
|
} else if (event.data?.type === 'admin:close') {
|
||||||
adminPanelOpen.value = false
|
adminPanelOpen.value = false
|
||||||
|
} else if (event.data?.type === 'phone:tones') {
|
||||||
|
phone.setCustomTones(event.data.data)
|
||||||
} else if (event.data?.type === 'custom-apps:catalog') {
|
} else if (event.data?.type === 'custom-apps:catalog') {
|
||||||
appCatalog.replaceCatalog(event.data.data)
|
appCatalog.replaceCatalog(event.data.data)
|
||||||
const catalogPayload = event.data.data as
|
const catalogPayload = event.data.data as
|
||||||
@@ -1315,20 +1320,17 @@ async function closePhone(): Promise<void> {
|
|||||||
function onKeydown(event: KeyboardEvent): void {
|
function onKeydown(event: KeyboardEvent): void {
|
||||||
if (event.key !== 'Escape') return
|
if (event.key !== 'Escape') return
|
||||||
if (simPicker.value) {
|
if (simPicker.value) {
|
||||||
event.preventDefault()
|
if (!consumeEscape(event)) return
|
||||||
void closeSimPicker()
|
void closeSimPicker()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
queueMicrotask(() => {
|
if (!phone.isOpen || activitySuspended.value || !consumeEscape(event)) return
|
||||||
if (event.defaultPrevented || !phone.isOpen || activitySuspended.value)
|
|
||||||
return
|
|
||||||
if (controlCenterOpened.value) {
|
if (controlCenterOpened.value) {
|
||||||
controlCenterOpened.value = false
|
controlCenterOpened.value = false
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
void closePhone()
|
void closePhone()
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function onSystemColorSchemeChange(event: MediaQueryListEvent): void {
|
function onSystemColorSchemeChange(event: MediaQueryListEvent): void {
|
||||||
|
|||||||
@@ -70,6 +70,43 @@ describe('browser development preview contract', () => {
|
|||||||
expect(source).toContain(':device-pixel-ratio="browserDevicePixelRatio"')
|
expect(source).toContain(':device-pixel-ratio="browserDevicePixelRatio"')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('clips composited app and overlay layers to the curved display', () => {
|
||||||
|
expect(mainCss).toMatch(
|
||||||
|
/\.phone-screen\s*\{[^}]*--phone-screen-radius:\s*40px;[^}]*overflow:\s*hidden;[^}]*border-radius:\s*var\(--phone-screen-radius\);[^}]*clip-path:\s*inset\(0 round var\(--phone-screen-radius\)\);/s,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('replaces the CEF button focus rectangle around the home indicator', () => {
|
||||||
|
expect(mainCss).toMatch(
|
||||||
|
/\.phone-home-indicator:focus\s*\{[^}]*outline:\s*none;/s,
|
||||||
|
)
|
||||||
|
expect(mainCss).toMatch(
|
||||||
|
/\.phone-home-indicator:focus-visible span\s*\{[^}]*0 0 0 2px #0a84ff,/s,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('replaces rectangular CEF focus outlines on the side hardware controls', () => {
|
||||||
|
expect(mainCss).toMatch(
|
||||||
|
/\.phone-hardware-button:focus\s*\{[^}]*outline:\s*none;/s,
|
||||||
|
)
|
||||||
|
expect(mainCss).toMatch(
|
||||||
|
/\.phone-hardware-button:focus-visible::after\s*\{[^}]*width:\s*3px;[^}]*height:\s*24px;[^}]*border-radius:\s*999px;[^}]*background:\s*#0a84ff;/s,
|
||||||
|
)
|
||||||
|
expect(mainCss).not.toMatch(
|
||||||
|
/\.phone-hardware-button:focus-visible\s*\{[^}]*outline:/s,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('consumes Escape synchronously before FiveM can open the pause menu', () => {
|
||||||
|
expect(source).toContain("import { consumeEscape } from '@/utils/keyboard'")
|
||||||
|
expect(source).toContain(
|
||||||
|
'if (!phone.isOpen || activitySuspended.value || !consumeEscape(event)) return',
|
||||||
|
)
|
||||||
|
expect(source).not.toMatch(
|
||||||
|
/function onKeydown\(event: KeyboardEvent\): void \{[\s\S]*?queueMicrotask/,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
it('maps the visible device side controls to phone actions', () => {
|
it('maps the visible device side controls to phone actions', () => {
|
||||||
expect(source).toContain('@click="toggleHardwareAlertMute"')
|
expect(source).toContain('@click="toggleHardwareAlertMute"')
|
||||||
expect(source).toContain('@click="changeHardwareAlertVolume(10)"')
|
expect(source).toContain('@click="changeHardwareAlertVolume(10)"')
|
||||||
|
|||||||
@@ -380,9 +380,27 @@ button {
|
|||||||
.phone-hardware-button:disabled {
|
.phone-hardware-button:disabled {
|
||||||
cursor: default;
|
cursor: default;
|
||||||
}
|
}
|
||||||
.phone-hardware-button:focus-visible {
|
.phone-hardware-button:focus {
|
||||||
outline: 2px solid #fff;
|
outline: none;
|
||||||
outline-offset: -6px;
|
}
|
||||||
|
.phone-hardware-button:focus-visible::after {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
width: 3px;
|
||||||
|
height: 24px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #0a84ff;
|
||||||
|
box-shadow: 0 0 0 1px rgb(255 255 255 / 90%);
|
||||||
|
content: '';
|
||||||
|
transform: translateY(-50%);
|
||||||
|
}
|
||||||
|
.phone-hardware-button--action:focus-visible::after,
|
||||||
|
.phone-hardware-button--volume-up:focus-visible::after,
|
||||||
|
.phone-hardware-button--volume-down:focus-visible::after {
|
||||||
|
right: 7px;
|
||||||
|
}
|
||||||
|
.phone-hardware-button--power:focus-visible::after {
|
||||||
|
left: 7px;
|
||||||
}
|
}
|
||||||
.phone-hardware-button--action {
|
.phone-hardware-button--action {
|
||||||
top: 176px;
|
top: 176px;
|
||||||
@@ -460,6 +478,7 @@ button {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.phone-screen {
|
.phone-screen {
|
||||||
|
--phone-screen-radius: 40px;
|
||||||
--phone-screen-portrait-ratio: 2.30951;
|
--phone-screen-portrait-ratio: 2.30951;
|
||||||
position: relative;
|
position: relative;
|
||||||
container-type: size;
|
container-type: size;
|
||||||
@@ -469,7 +488,8 @@ button {
|
|||||||
height: 98%;
|
height: 98%;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: #08080a;
|
background: #08080a;
|
||||||
border-radius: 40px;
|
border-radius: var(--phone-screen-radius);
|
||||||
|
clip-path: inset(0 round var(--phone-screen-radius));
|
||||||
}
|
}
|
||||||
.phone-screen--camera-landscape {
|
.phone-screen--camera-landscape {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
@@ -856,6 +876,14 @@ button {
|
|||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
box-shadow: 0 1px 4px #0008;
|
box-shadow: 0 1px 4px #0008;
|
||||||
}
|
}
|
||||||
|
.phone-home-indicator:focus {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
.phone-home-indicator:focus-visible span {
|
||||||
|
box-shadow:
|
||||||
|
0 0 0 2px #0a84ff,
|
||||||
|
0 1px 4px #0008;
|
||||||
|
}
|
||||||
.phone-home-indicator--interactive {
|
.phone-home-indicator--interactive {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import type { AdminConfiguratorDescribe } from '@/utils/adminConfiguratorDescrip
|
|||||||
|
|
||||||
export type AdminConfigEditorLabels = {
|
export type AdminConfigEditorLabels = {
|
||||||
addField: string
|
addField: string
|
||||||
|
addJob: string
|
||||||
addRow: string
|
addRow: string
|
||||||
configuredSecret: string
|
configuredSecret: string
|
||||||
convertToList: string
|
convertToList: string
|
||||||
@@ -27,6 +28,7 @@ export type AdminConfigEditorLabels = {
|
|||||||
emptyTable: string
|
emptyTable: string
|
||||||
entry: string
|
entry: string
|
||||||
general: string
|
general: string
|
||||||
|
jobPlaceholder: string
|
||||||
keyPlaceholder: string
|
keyPlaceholder: string
|
||||||
list: string
|
list: string
|
||||||
remove: string
|
remove: string
|
||||||
@@ -149,6 +151,11 @@ const canExtendTable = computed(
|
|||||||
!vectorType.value &&
|
!vectorType.value &&
|
||||||
(!tableStructure.value || tableStructure.value.mutableKeys === true),
|
(!tableStructure.value || tableStructure.value.mutableKeys === true),
|
||||||
)
|
)
|
||||||
|
const isJobTable = computed(
|
||||||
|
() =>
|
||||||
|
props.path === 'Radio.DisplayName.AllowedJobs' ||
|
||||||
|
/^Radio\.LockedChannels\[\d+\]\.jobs$/.test(props.path),
|
||||||
|
)
|
||||||
const usesFixedTableLayout = computed(
|
const usesFixedTableLayout = computed(
|
||||||
() =>
|
() =>
|
||||||
Boolean(tableStructure.value) && tableStructure.value?.mutableKeys !== true,
|
Boolean(tableStructure.value) && tableStructure.value?.mutableKeys !== true,
|
||||||
@@ -371,6 +378,19 @@ function updateOptionalString(event: Event): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function updateNewObjectKey(event: Event): void {
|
||||||
|
const target = event.target
|
||||||
|
if (!(target instanceof HTMLInputElement)) return
|
||||||
|
const value = isJobTable.value
|
||||||
|
? target.value
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9_-]/g, '')
|
||||||
|
.slice(0, 64)
|
||||||
|
: target.value
|
||||||
|
newObjectKey.value = value
|
||||||
|
target.value = value
|
||||||
|
}
|
||||||
|
|
||||||
function toggleOptionalString(event: Event): void {
|
function toggleOptionalString(event: Event): void {
|
||||||
const target = event.target
|
const target = event.target
|
||||||
if (!(target instanceof HTMLInputElement)) return
|
if (!(target instanceof HTMLInputElement)) return
|
||||||
@@ -380,10 +400,10 @@ function toggleOptionalString(event: Event): void {
|
|||||||
function addListRow(): void {
|
function addListRow(): void {
|
||||||
const rows = Array.isArray(props.modelValue) ? [...props.modelValue] : []
|
const rows = Array.isArray(props.modelValue) ? [...props.modelValue] : []
|
||||||
const index = rows.length
|
const index = rows.length
|
||||||
const value = rows.length
|
const value = listTemplate.value
|
||||||
? blankLike(rows[0])
|
|
||||||
: listTemplate.value
|
|
||||||
? blankFromConfiguratorStructure(listTemplate.value)
|
? blankFromConfiguratorStructure(listTemplate.value)
|
||||||
|
: rows.length
|
||||||
|
? blankLike(rows[0])
|
||||||
: blankValue(newArrayKind.value)
|
: blankValue(newArrayKind.value)
|
||||||
rows.push(value)
|
rows.push(value)
|
||||||
emit('update:modelValue', rows)
|
emit('update:modelValue', rows)
|
||||||
@@ -1015,11 +1035,15 @@ function mapEntryStructure(
|
|||||||
@submit.prevent="addTableField"
|
@submit.prevent="addTableField"
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
v-model="newObjectKey"
|
:value="newObjectKey"
|
||||||
type="text"
|
type="text"
|
||||||
:disabled="disabled"
|
:disabled="disabled"
|
||||||
:placeholder="labels.keyPlaceholder"
|
:placeholder="
|
||||||
:aria-label="labels.keyPlaceholder"
|
isJobTable ? labels.jobPlaceholder : labels.keyPlaceholder
|
||||||
|
"
|
||||||
|
:aria-label="isJobTable ? labels.jobPlaceholder : labels.keyPlaceholder"
|
||||||
|
autocomplete="off"
|
||||||
|
@input="updateNewObjectKey"
|
||||||
/>
|
/>
|
||||||
<select
|
<select
|
||||||
v-if="!tableStructure"
|
v-if="!tableStructure"
|
||||||
@@ -1039,7 +1063,7 @@ function mapEntryStructure(
|
|||||||
{{ structureTypeLabel(tableStructure.template) }}
|
{{ structureTypeLabel(tableStructure.template) }}
|
||||||
</span>
|
</span>
|
||||||
<button type="submit" :disabled="disabled || !canAddTableField">
|
<button type="submit" :disabled="disabled || !canAddTableField">
|
||||||
<Plus :size="13" />{{ labels.addField }}
|
<Plus :size="13" />{{ isJobTable ? labels.addJob : labels.addField }}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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',
|
'messages',
|
||||||
'calls',
|
'calls',
|
||||||
'moderation',
|
'moderation',
|
||||||
|
'tones',
|
||||||
'audit',
|
'audit',
|
||||||
'configurator',
|
'configurator',
|
||||||
]) {
|
]) {
|
||||||
@@ -440,6 +441,9 @@ describe('standalone admin panel contracts', () => {
|
|||||||
expect(source).toContain("selectConfiguratorScope('media')")
|
expect(source).toContain("selectConfiguratorScope('media')")
|
||||||
expect(source).not.toContain('class="admin-panel-config-meta"')
|
expect(source).not.toContain('class="admin-panel-config-meta"')
|
||||||
expect(configuratorValueEditor).toContain('function addListRow()')
|
expect(configuratorValueEditor).toContain('function addListRow()')
|
||||||
|
expect(configuratorValueEditor).toMatch(
|
||||||
|
/const value = listTemplate\.value\s+\? blankFromConfiguratorStructure\(listTemplate\.value\)\s+: rows\.length\s+\? blankLike\(rows\[0\]\)/,
|
||||||
|
)
|
||||||
expect(configuratorValueEditor).toContain('function addTableField()')
|
expect(configuratorValueEditor).toContain('function addTableField()')
|
||||||
expect(configuratorValueEditor).toContain(
|
expect(configuratorValueEditor).toContain(
|
||||||
'const canExtendTable = computed(',
|
'const canExtendTable = computed(',
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import {
|
import {
|
||||||
ChartNoAxesCombined,
|
ChartNoAxesCombined,
|
||||||
BadgeDollarSign,
|
BadgeDollarSign,
|
||||||
|
BellRing,
|
||||||
BriefcaseBusiness,
|
BriefcaseBusiness,
|
||||||
Check,
|
Check,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
@@ -65,6 +66,7 @@ import { nuiCall } from '@/utils/nui'
|
|||||||
import AdminConfigValueEditor, {
|
import AdminConfigValueEditor, {
|
||||||
type AdminConfigEditorLabels,
|
type AdminConfigEditorLabels,
|
||||||
} from './AdminConfigValueEditor.vue'
|
} from './AdminConfigValueEditor.vue'
|
||||||
|
import AdminCustomToneManager from './AdminCustomToneManager.vue'
|
||||||
|
|
||||||
type AdminTab =
|
type AdminTab =
|
||||||
| 'overview'
|
| 'overview'
|
||||||
@@ -75,6 +77,7 @@ type AdminTab =
|
|||||||
| 'messages'
|
| 'messages'
|
||||||
| 'calls'
|
| 'calls'
|
||||||
| 'moderation'
|
| 'moderation'
|
||||||
|
| 'tones'
|
||||||
| 'audit'
|
| 'audit'
|
||||||
| 'configurator'
|
| 'configurator'
|
||||||
type ConfiguratorScope = 'config' | 'media'
|
type ConfiguratorScope = 'config' | 'media'
|
||||||
@@ -400,6 +403,7 @@ function configuratorDescription(
|
|||||||
|
|
||||||
const configuratorEditorLabels = computed<AdminConfigEditorLabels>(() => ({
|
const configuratorEditorLabels = computed<AdminConfigEditorLabels>(() => ({
|
||||||
addField: t('configurator.table.addField'),
|
addField: t('configurator.table.addField'),
|
||||||
|
addJob: t('configurator.table.addJob'),
|
||||||
addRow: t('configurator.table.addRow'),
|
addRow: t('configurator.table.addRow'),
|
||||||
configuredSecret: t('configurator.secretConfigured'),
|
configuredSecret: t('configurator.secretConfigured'),
|
||||||
convertToList: t('configurator.table.convertToList'),
|
convertToList: t('configurator.table.convertToList'),
|
||||||
@@ -409,6 +413,7 @@ const configuratorEditorLabels = computed<AdminConfigEditorLabels>(() => ({
|
|||||||
emptyTable: t('configurator.table.emptyTable'),
|
emptyTable: t('configurator.table.emptyTable'),
|
||||||
entry: t('configurator.table.entry'),
|
entry: t('configurator.table.entry'),
|
||||||
general: configuratorLocaleText('configurator.table.general', 'General'),
|
general: configuratorLocaleText('configurator.table.general', 'General'),
|
||||||
|
jobPlaceholder: t('configurator.table.jobPlaceholder'),
|
||||||
keyPlaceholder: t('configurator.table.keyPlaceholder'),
|
keyPlaceholder: t('configurator.table.keyPlaceholder'),
|
||||||
list: t('configurator.table.list'),
|
list: t('configurator.table.list'),
|
||||||
remove: t('configurator.table.remove'),
|
remove: t('configurator.table.remove'),
|
||||||
@@ -538,8 +543,15 @@ function selectTab(nextTab: AdminTab): void {
|
|||||||
if (nextTab === 'overview' && admin.initialized && !admin.loading) {
|
if (nextTab === 'overview' && admin.initialized && !admin.loading) {
|
||||||
void admin.load()
|
void admin.load()
|
||||||
}
|
}
|
||||||
if (nextTab === 'configurator' && !admin.configurator) {
|
if (nextTab === 'configurator') {
|
||||||
void admin.loadConfigurator().then((loaded) => {
|
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')
|
if (!loaded) showToast(errorText(), 'error')
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -863,6 +875,8 @@ onBeforeUnmount(() => {
|
|||||||
<strong>{{
|
<strong>{{
|
||||||
tab === 'configurator'
|
tab === 'configurator'
|
||||||
? t('configurator.context')
|
? t('configurator.context')
|
||||||
|
: tab === 'tones'
|
||||||
|
? t('configurator.customTones.context')
|
||||||
: admin.selectedPlayer?.name || t('editor.noSelection')
|
: admin.selectedPlayer?.name || t('editor.noSelection')
|
||||||
}}</strong>
|
}}</strong>
|
||||||
</div>
|
</div>
|
||||||
@@ -969,6 +983,15 @@ onBeforeUnmount(() => {
|
|||||||
>
|
>
|
||||||
<ShieldAlert :size="19" />
|
<ShieldAlert :size="19" />
|
||||||
</button>
|
</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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
:class="{ 'is-active': tab === 'audit' }"
|
:class="{ 'is-active': tab === 'audit' }"
|
||||||
@@ -1064,6 +1087,14 @@ onBeforeUnmount(() => {
|
|||||||
</span>
|
</span>
|
||||||
<ChevronRight :size="14" />
|
<ChevronRight :size="14" />
|
||||||
</button>
|
</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')">
|
<button type="button" @click="selectTab('configurator')">
|
||||||
<Settings2 :size="17" />
|
<Settings2 :size="17" />
|
||||||
<span>
|
<span>
|
||||||
@@ -1075,6 +1106,46 @@ onBeforeUnmount(() => {
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</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'">
|
<template v-else-if="tab === 'configurator'">
|
||||||
<div class="admin-panel-directory__header">
|
<div class="admin-panel-directory__header">
|
||||||
<div>
|
<div>
|
||||||
@@ -1369,6 +1440,23 @@ onBeforeUnmount(() => {
|
|||||||
</article>
|
</article>
|
||||||
</section>
|
</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
|
<section
|
||||||
v-else-if="tab === 'configurator'"
|
v-else-if="tab === 'configurator'"
|
||||||
class="admin-panel-editor__scroll"
|
class="admin-panel-editor__scroll"
|
||||||
@@ -2926,6 +3014,47 @@ button:disabled {
|
|||||||
background: var(--admin-row-hover);
|
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 {
|
.admin-panel-audit-icon {
|
||||||
width: 27px;
|
width: 27px;
|
||||||
height: 27px;
|
height: 27px;
|
||||||
|
|||||||
@@ -24,4 +24,13 @@ describe('EasyShareSheet Sky UI contract', () => {
|
|||||||
/\.easyshare-history\s*\{[^}]*overflow:\s*hidden[^}]*background:\s*var\(--easyshare-list-surface\)/s,
|
/\.easyshare-history\s*\{[^}]*overflow:\s*hidden[^}]*background:\s*var\(--easyshare-list-surface\)/s,
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('only exposes and opens installed share destinations', () => {
|
||||||
|
expect(source).toContain("if (appStore.isInstalled('flare'))")
|
||||||
|
expect(source).toContain("if (appStore.isInstalled('darkchat'))")
|
||||||
|
expect(source).toContain('.filter((id) => appStore.isInstalled(id))')
|
||||||
|
expect(source).toContain('if (!appStore.isInstalled(kind)) return')
|
||||||
|
expect(source).toContain('if (!appStore.isInstalled(appId)) return')
|
||||||
|
expect(source).not.toContain('appStore.homeLayout.hidden.includes')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ const sharePeople = computed(() => {
|
|||||||
}> = []
|
}> = []
|
||||||
const phoneNumbers = new Set<string>()
|
const phoneNumbers = new Set<string>()
|
||||||
|
|
||||||
if (!appStore.homeLayout.hidden.includes('flare')) {
|
if (appStore.isInstalled('flare')) {
|
||||||
for (const match of flare.matches) {
|
for (const match of flare.matches) {
|
||||||
people.push({
|
people.push({
|
||||||
avatar: match.profile.photoUrls[0],
|
avatar: match.profile.photoUrls[0],
|
||||||
@@ -102,7 +102,7 @@ const sharePeople = computed(() => {
|
|||||||
phoneNumbers.add(contact.phone_number)
|
phoneNumbers.add(contact.phone_number)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!appStore.homeLayout.hidden.includes('darkchat')) {
|
if (appStore.isInstalled('darkchat')) {
|
||||||
for (const conversation of darkChat.conversations.slice(0, 8)) {
|
for (const conversation of darkChat.conversations.slice(0, 8)) {
|
||||||
people.push({
|
people.push({
|
||||||
kind: 'darkchat',
|
kind: 'darkchat',
|
||||||
@@ -116,7 +116,7 @@ const sharePeople = computed(() => {
|
|||||||
})
|
})
|
||||||
const shareApps = computed(() =>
|
const shareApps = computed(() =>
|
||||||
(easyShare.payload ? easyShareDestinationAppIds(easyShare.payload) : [])
|
(easyShare.payload ? easyShareDestinationAppIds(easyShare.payload) : [])
|
||||||
.filter((id) => !appStore.homeLayout.hidden.includes(id))
|
.filter((id) => appStore.isInstalled(id))
|
||||||
.flatMap((id) => {
|
.flatMap((id) => {
|
||||||
const app = getPhoneApp(id)
|
const app = getPhoneApp(id)
|
||||||
return app ? [{ app, id }] : []
|
return app ? [{ app, id }] : []
|
||||||
@@ -195,18 +195,21 @@ function endDrag(event: PointerEvent): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function shareToChat(kind: EasyShareChatApp, targetId: string): void {
|
function shareToChat(kind: EasyShareChatApp, targetId: string): void {
|
||||||
|
if (!appStore.isInstalled(kind)) return
|
||||||
if (!easyShare.prepareChatDraft(kind, targetId)) return
|
if (!easyShare.prepareChatDraft(kind, targetId)) return
|
||||||
close()
|
close()
|
||||||
void router.push(`/apps/${kind}`)
|
void router.push(`/apps/${kind}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
function openChatApp(kind: EasyShareChatApp): void {
|
function openChatApp(kind: EasyShareChatApp): void {
|
||||||
|
if (!appStore.isInstalled(kind)) return
|
||||||
if (!easyShare.prepareChatDraft(kind)) return
|
if (!easyShare.prepareChatDraft(kind)) return
|
||||||
close()
|
close()
|
||||||
void router.push(`/apps/${kind}`)
|
void router.push(`/apps/${kind}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
function openShareApp(appId: EasyShareDestinationApp): void {
|
function openShareApp(appId: EasyShareDestinationApp): void {
|
||||||
|
if (!appStore.isInstalled(appId)) return
|
||||||
if (appId === 'messages' || appId === 'darkchat' || appId === 'flare') {
|
if (appId === 'messages' || appId === 'darkchat' || appId === 'flare') {
|
||||||
openChatApp(appId)
|
openChatApp(appId)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -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"',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { readFileSync } from 'node:fs'
|
||||||
|
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
const readResourceFile = (path: string) =>
|
||||||
|
readFileSync(
|
||||||
|
new URL(`../../sky_phone/${path}`, import.meta.url),
|
||||||
|
'utf8',
|
||||||
|
).replace(/\r\n/g, '\n')
|
||||||
|
|
||||||
|
describe('housing provider contracts', () => {
|
||||||
|
it('registers TGIANN House with server-authorized owner properties', () => {
|
||||||
|
const adapter = readResourceFile('source/bridge/server/housing/tgiann.lua')
|
||||||
|
|
||||||
|
expect(adapter).toContain('local resource_name = "tgiann-house"')
|
||||||
|
expect(adapter).toContain('FROM `tgiann_house`')
|
||||||
|
expect(adapter).toContain('WHERE `owner` = ?')
|
||||||
|
expect(adapter).toContain('Bridge.Framework.GetIdentifier(source)')
|
||||||
|
expect(adapter).toContain(
|
||||||
|
'Bridge.Housing.RegisterProvider(provider_name, {',
|
||||||
|
)
|
||||||
|
expect(adapter).toContain('return nil, "property_access_denied"')
|
||||||
|
expect(adapter).not.toContain('`houseKeys`')
|
||||||
|
expect(adapter).not.toContain('tgiann-house:getPlayerHouses')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses the published client export for entrance waypoints only', () => {
|
||||||
|
const adapter = readResourceFile('source/bridge/client/housing/tgiann.lua')
|
||||||
|
|
||||||
|
expect(adapter).toContain(
|
||||||
|
'return exports[resource_name]:getHouseData(house)',
|
||||||
|
)
|
||||||
|
expect(adapter).toContain(
|
||||||
|
'Bridge.Normalize.Coordinates(house_data.doorCoord)',
|
||||||
|
)
|
||||||
|
expect(adapter).toContain(
|
||||||
|
'Bridge.Housing.RegisterClientProvider(provider_name, {',
|
||||||
|
)
|
||||||
|
expect(adapter).toContain('SetNewWaypoint(')
|
||||||
|
expect(adapter).not.toContain('enterHouse(')
|
||||||
|
expect(adapter).not.toContain('forceOpenDoorHouse')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('advertises TGIANN in housing configuration and documentation', () => {
|
||||||
|
const config = readResourceFile('config/config.lua')
|
||||||
|
const readme = readFileSync(
|
||||||
|
new URL('../../README.md', import.meta.url),
|
||||||
|
'utf8',
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(config).toContain(
|
||||||
|
'"rtx", "quasar", "tgiann", "vms", "rx", "nolag", "sn"',
|
||||||
|
)
|
||||||
|
expect(readme).toContain('Quasar Housing, TGIANN House, VMS Housing')
|
||||||
|
expect(readme).toContain('`quasar`, `tgiann`, `vms`')
|
||||||
|
expect(readme).toContain(
|
||||||
|
'expects `tgiann-core` to start before `tgiann-house`',
|
||||||
|
)
|
||||||
|
expect(readme).toContain(
|
||||||
|
'lists server-authorized owner properties, and supports entrance waypoints',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -30,13 +30,17 @@ describe('fixed server permissions', () => {
|
|||||||
expect(config).not.toContain('AdminGroups =')
|
expect(config).not.toContain('AdminGroups =')
|
||||||
expect(configDefault).not.toContain('Config.PhoneConfigurator')
|
expect(configDefault).not.toContain('Config.PhoneConfigurator')
|
||||||
expect(configDefault).not.toContain('Config.CommandPermissions')
|
expect(configDefault).not.toContain('Config.CommandPermissions')
|
||||||
|
expect(configDefault).not.toContain('Config.CustomTones')
|
||||||
expect(configDefault).not.toContain('AdminGroups =')
|
expect(configDefault).not.toContain('AdminGroups =')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('keeps fixed permissions outside SQL and removes legacy group fields', () => {
|
it('keeps fixed permissions outside SQL and removes legacy group fields', () => {
|
||||||
expect(configurator).toContain('key ~= "CommandPermissions"')
|
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.CommandPermissions')
|
||||||
|
expect(configuratorFixture).toContain('delete config.CustomTones')
|
||||||
for (const path of [
|
for (const path of [
|
||||||
'AdminPanel.AdminGroups',
|
'AdminPanel.AdminGroups',
|
||||||
'TestData.AdminGroups',
|
'TestData.AdminGroups',
|
||||||
@@ -55,7 +59,7 @@ describe('fixed server permissions', () => {
|
|||||||
'^1 Configure all phone and media settings IN GAME through /phonepanel.^0',
|
'^1 Configure all phone and media settings IN GAME through /phonepanel.^0',
|
||||||
)
|
)
|
||||||
expect(configurator).toContain(
|
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',
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import { readFileSync } from 'node:fs'
|
||||||
|
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
const resourceSource = (path: string): string =>
|
||||||
|
readFileSync(new URL(`../../sky_phone/${path}`, import.meta.url), 'utf8')
|
||||||
|
|
||||||
|
const configSource = resourceSource('config/config.lua')
|
||||||
|
const configuratorSource = resourceSource(
|
||||||
|
'source/server/phone_configurator.lua',
|
||||||
|
)
|
||||||
|
const radioSource = resourceSource('source/server/radio.lua')
|
||||||
|
const editorSource = readFileSync(
|
||||||
|
new URL('./components/AdminConfigValueEditor.vue', import.meta.url),
|
||||||
|
'utf8',
|
||||||
|
)
|
||||||
|
|
||||||
|
describe('radio configurator access contract', () => {
|
||||||
|
it('lets the server allow everyone or enforce configured job grades', () => {
|
||||||
|
expect(configSource).toMatch(
|
||||||
|
/DisplayName\s*=\s*\{[\s\S]*?AllowEveryone\s*=\s*false,[\s\S]*?AllowedJobs\s*=\s*\{/,
|
||||||
|
)
|
||||||
|
|
||||||
|
const start = radioSource.indexOf(
|
||||||
|
'local function can_set_display_name(source)',
|
||||||
|
)
|
||||||
|
const end = radioSource.indexOf(
|
||||||
|
'\nlocal function normalize_display_name',
|
||||||
|
start,
|
||||||
|
)
|
||||||
|
const permissionSource = radioSource.slice(start, end)
|
||||||
|
|
||||||
|
expect(permissionSource).toContain('if config.AllowEveryone == true then')
|
||||||
|
expect(permissionSource.indexOf('config.AllowEveryone')).toBeLessThan(
|
||||||
|
permissionSource.indexOf('Bridge.Framework.GetJob(source)'),
|
||||||
|
)
|
||||||
|
expect(permissionSource).toContain('config.AllowedJobs[job.name]')
|
||||||
|
expect(permissionSource).toContain('(tonumber(job.grade) or 0)')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps both radio job tables mutable and validates their value types', () => {
|
||||||
|
expect(configuratorSource).toContain(
|
||||||
|
'path == "Radio.DisplayName.AllowedJobs"',
|
||||||
|
)
|
||||||
|
expect(configuratorSource).toContain(
|
||||||
|
'path:match("^Radio%.LockedChannels%.%d+%.jobs$")',
|
||||||
|
)
|
||||||
|
expect(configuratorSource).toContain(
|
||||||
|
'path == "Companies.Definitions" or radio_job_entry_default(path) ~= nil',
|
||||||
|
)
|
||||||
|
expect(configuratorSource).toMatch(
|
||||||
|
/entryDefault = radio_job_default,[\s\S]*?mutableKeys = true,[\s\S]*?valueType = type\(radio_job_default\)/,
|
||||||
|
)
|
||||||
|
expect(configuratorSource).toContain('not key:match("^[a-z0-9_-]+$")')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows a compact job-name input for both radio job tables', () => {
|
||||||
|
expect(editorSource).toContain(
|
||||||
|
"props.path === 'Radio.DisplayName.AllowedJobs'",
|
||||||
|
)
|
||||||
|
expect(editorSource).toContain(
|
||||||
|
String.raw`/^Radio\.LockedChannels\[\d+\]\.jobs$/`,
|
||||||
|
)
|
||||||
|
expect(editorSource).toContain(
|
||||||
|
'isJobTable ? labels.jobPlaceholder : labels.keyPlaceholder',
|
||||||
|
)
|
||||||
|
expect(editorSource).toContain(
|
||||||
|
'isJobTable ? labels.addJob : labels.addField',
|
||||||
|
)
|
||||||
|
expect(editorSource).toContain('function updateNewObjectKey(event: Event)')
|
||||||
|
expect(editorSource).toContain('.toLowerCase()')
|
||||||
|
expect(editorSource).toContain(".replace(/[^a-z0-9_-]/g, '')")
|
||||||
|
expect(editorSource).toContain('.slice(0, 64)')
|
||||||
|
|
||||||
|
for (const locale of ['en', 'de', 'es']) {
|
||||||
|
const localeSource = resourceSource(`config/locales/${locale}.lua`)
|
||||||
|
expect(localeSource).toContain(
|
||||||
|
`Locales["${locale}"].Nui.AdminPanel.configurator.table.addJob`,
|
||||||
|
)
|
||||||
|
expect(localeSource).toContain(
|
||||||
|
`Locales["${locale}"].Nui.AdminPanel.configurator.table.jobPlaceholder`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -8,11 +8,17 @@ import type {
|
|||||||
AdminConfigurator,
|
AdminConfigurator,
|
||||||
AdminConfiguratorChange,
|
AdminConfiguratorChange,
|
||||||
AdminCredential,
|
AdminCredential,
|
||||||
|
AdminCustomTone,
|
||||||
|
AdminCustomToneCreate,
|
||||||
AdminMessageActivity,
|
AdminMessageActivity,
|
||||||
AdminPlayerDetail,
|
AdminPlayerDetail,
|
||||||
AdminPlayerSummary,
|
AdminPlayerSummary,
|
||||||
AdminStats,
|
AdminStats,
|
||||||
} from '@/types/admin'
|
} from '@/types/admin'
|
||||||
|
import {
|
||||||
|
cacheCustomPhoneTonePayload,
|
||||||
|
CUSTOM_TONE_CHUNK_CHARS,
|
||||||
|
} from '@/utils/customTones'
|
||||||
import { nuiCall, type NuiResponse } from '@/utils/nui'
|
import { nuiCall, type NuiResponse } from '@/utils/nui'
|
||||||
|
|
||||||
const EMPTY_STATS: AdminStats = {
|
const EMPTY_STATS: AdminStats = {
|
||||||
@@ -49,6 +55,8 @@ export const useAdminStore = defineStore('admin', {
|
|||||||
audit: [] as AdminAuditEntry[],
|
audit: [] as AdminAuditEntry[],
|
||||||
configurator: null as AdminConfigurator | null,
|
configurator: null as AdminConfigurator | null,
|
||||||
configuratorLoading: false,
|
configuratorLoading: false,
|
||||||
|
customTones: [] as AdminCustomTone[],
|
||||||
|
customTonesLoading: false,
|
||||||
detailLoading: false,
|
detailLoading: false,
|
||||||
disabledApps: [] as string[],
|
disabledApps: [] as string[],
|
||||||
error: '',
|
error: '',
|
||||||
@@ -205,6 +213,99 @@ export const useAdminStore = defineStore('admin', {
|
|||||||
}
|
}
|
||||||
return response
|
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(
|
async resetPasscode(
|
||||||
source: number,
|
source: number,
|
||||||
imei: string,
|
imei: string,
|
||||||
|
|||||||
@@ -264,6 +264,23 @@ describe('app store', () => {
|
|||||||
expect(apps.homeLayout.hidden).not.toContain('snake')
|
expect(apps.homeLayout.hidden).not.toContain('snake')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('uninstalls claimed Banking and Picstagram apps from every app state', () => {
|
||||||
|
const apps = useAppStoreStore()
|
||||||
|
apps.hydrate({ claimedApps: ['banking', 'picstagram'] })
|
||||||
|
mocks.phone.saveDeviceNamespace.mockClear()
|
||||||
|
|
||||||
|
for (const appId of ['banking', 'picstagram'] as const) {
|
||||||
|
expect(apps.isInstalled(appId)).toBe(true)
|
||||||
|
expect(apps.uninstallApp(appId)).toBe(true)
|
||||||
|
expect(apps.isInstalled(appId)).toBe(false)
|
||||||
|
expect(apps.claimedApps).not.toContain(appId)
|
||||||
|
expect(apps.uninstalledApps).toContain(appId)
|
||||||
|
expect(apps.homeLayout.hidden).toContain(appId)
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(mocks.phone.saveDeviceNamespace).toHaveBeenCalledTimes(2)
|
||||||
|
})
|
||||||
|
|
||||||
it('hydrates persisted removals while rejecting protected and invalid ids', () => {
|
it('hydrates persisted removals while rejecting protected and invalid ids', () => {
|
||||||
const apps = useAppStoreStore()
|
const apps = useAppStoreStore()
|
||||||
|
|
||||||
|
|||||||
@@ -4,14 +4,18 @@ import { ref } from 'vue'
|
|||||||
import { usePhoneStore } from '@/stores/phone'
|
import { usePhoneStore } from '@/stores/phone'
|
||||||
import type { PhoneCall, PhoneContact, RecentCall } from '@/types/phone'
|
import type { PhoneCall, PhoneContact, RecentCall } from '@/types/phone'
|
||||||
import { nuiCall, type NuiResponse } from '@/utils/nui'
|
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 {
|
import {
|
||||||
playPhoneTone,
|
playPhoneTone,
|
||||||
playPhoneVibration,
|
playPhoneVibration,
|
||||||
type PhoneToneId,
|
type PhoneToneId,
|
||||||
} from '@/utils/tones'
|
} from '@/utils/tones'
|
||||||
|
|
||||||
const RINGTONE_TONES: Record<RingtoneId, PhoneToneId> = {
|
const RINGTONE_TONES: Record<BuiltInRingtoneId, PhoneToneId> = {
|
||||||
horizon: 'aurora',
|
horizon: 'aurora',
|
||||||
pulse: 'signal',
|
pulse: 'signal',
|
||||||
skyline: 'apex',
|
skyline: 'apex',
|
||||||
@@ -24,6 +28,21 @@ export const useCallsStore = defineStore('calls', () => {
|
|||||||
const recents = ref<RecentCall[]>([])
|
const recents = ref<RecentCall[]>([])
|
||||||
let stopRingtone: (() => void) | null = null
|
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> {
|
async function bootstrap(): Promise<void> {
|
||||||
await Promise.all([loadContacts(), loadRecents()])
|
await Promise.all([loadContacts(), loadRecents()])
|
||||||
}
|
}
|
||||||
@@ -173,11 +192,7 @@ export const useCallsStore = defineStore('calls', () => {
|
|||||||
phone.preferences.settings.ringtoneVolume === 0
|
phone.preferences.settings.ringtoneVolume === 0
|
||||||
stopRingtone = alertsMuted
|
stopRingtone = alertsMuted
|
||||||
? playPhoneVibration('call', true)
|
? playPhoneVibration('call', true)
|
||||||
: playPhoneTone(
|
: playSelectedRingtone(phone.preferences.settings.ringtoneVolume)
|
||||||
RINGTONE_TONES[phone.preferences.settings.ringtone],
|
|
||||||
phone.preferences.settings.ringtoneVolume,
|
|
||||||
true,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
if (!['ringing', 'connected'].includes(call.state)) {
|
if (!['ringing', 'connected'].includes(call.state)) {
|
||||||
window.setTimeout(() => {
|
window.setTimeout(() => {
|
||||||
|
|||||||
@@ -5,8 +5,10 @@ import { isPhoneAppId } from '@/config/apps'
|
|||||||
import { usePhoneStore } from '@/stores/phone'
|
import { usePhoneStore } from '@/stores/phone'
|
||||||
import type { LaunchablePhoneAppId } from '@/types/apps'
|
import type { LaunchablePhoneAppId } from '@/types/apps'
|
||||||
import { nuiCall } from '@/utils/nui'
|
import { nuiCall } from '@/utils/nui'
|
||||||
|
import { findCustomPhoneTone, playCustomPhoneTone } from '@/utils/customTones'
|
||||||
import {
|
import {
|
||||||
DEFAULT_APP_NOTIFICATION_PREFERENCES,
|
DEFAULT_APP_NOTIFICATION_PREFERENCES,
|
||||||
|
isBuiltInNotificationSoundId,
|
||||||
type PhonePreferencesV1,
|
type PhonePreferencesV1,
|
||||||
} from '@/utils/preferences'
|
} from '@/utils/preferences'
|
||||||
import {
|
import {
|
||||||
@@ -190,18 +192,34 @@ export const useNotificationsStore = defineStore('notifications', () => {
|
|||||||
const alertsMuted =
|
const alertsMuted =
|
||||||
preferences.settings.notificationVolume === 0 &&
|
preferences.settings.notificationVolume === 0 &&
|
||||||
preferences.settings.ringtoneVolume === 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
|
const volume = notification.critical
|
||||||
? preferences.settings.ringtoneVolume
|
? preferences.settings.ringtoneVolume
|
||||||
: preferences.settings.notificationVolume
|
: preferences.settings.notificationVolume
|
||||||
stopToneHandles.set(
|
stopToneHandles.set(
|
||||||
notification.id,
|
notification.id,
|
||||||
alertsMuted
|
alertsMuted
|
||||||
? playPhoneVibration(
|
? playPhoneVibration('notification', !!notification.persistent)
|
||||||
'notification',
|
: customSound
|
||||||
|
? playCustomPhoneTone(
|
||||||
|
customSound,
|
||||||
|
volume,
|
||||||
!!notification.persistent,
|
!!notification.persistent,
|
||||||
)
|
)
|
||||||
: playPhoneTone(sound, volume, !!notification.persistent),
|
: playPhoneTone(
|
||||||
|
notification.sound ??
|
||||||
|
(isBuiltInNotificationSoundId(selectedSound)
|
||||||
|
? selectedSound
|
||||||
|
: 'chime'),
|
||||||
|
volume,
|
||||||
|
!!notification.persistent,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,11 @@ import type {
|
|||||||
} from '@/types/device'
|
} from '@/types/device'
|
||||||
import { clampPage } from '@/utils/pages'
|
import { clampPage } from '@/utils/pages'
|
||||||
import { cloneJsonData } from '@/utils/clone'
|
import { cloneJsonData } from '@/utils/clone'
|
||||||
|
import {
|
||||||
|
EMPTY_CUSTOM_PHONE_TONES,
|
||||||
|
isCustomTonePreferenceId,
|
||||||
|
parseCustomPhoneToneCatalog,
|
||||||
|
} from '@/utils/customTones'
|
||||||
import { nuiCall } from '@/utils/nui'
|
import { nuiCall } from '@/utils/nui'
|
||||||
import type { NuiResponse } from '@/utils/nui'
|
import type { NuiResponse } from '@/utils/nui'
|
||||||
import {
|
import {
|
||||||
@@ -823,6 +828,7 @@ const adminPanelFallbackLocales = {
|
|||||||
messages: 'Messages',
|
messages: 'Messages',
|
||||||
calls: 'Calls',
|
calls: 'Calls',
|
||||||
moderation: 'Moderation',
|
moderation: 'Moderation',
|
||||||
|
tones: 'Sounds',
|
||||||
audit: 'Audit',
|
audit: 'Audit',
|
||||||
configurator: 'Phone configurator',
|
configurator: 'Phone configurator',
|
||||||
},
|
},
|
||||||
@@ -846,6 +852,7 @@ const adminPanelFallbackLocales = {
|
|||||||
messageFeature: 'Review recent SMS activity',
|
messageFeature: 'Review recent SMS activity',
|
||||||
callFeature: 'Review recent call activity',
|
callFeature: 'Review recent call activity',
|
||||||
moderationFeature: 'Reset access, number, or device data',
|
moderationFeature: 'Reset access, number, or device data',
|
||||||
|
tonesFeature: 'Manage ringtones and notification sounds',
|
||||||
auditFeature: 'Review sensitive admin actions',
|
auditFeature: 'Review sensitive admin actions',
|
||||||
configuratorFeature: 'Manage config.lua and media.lua through SQL',
|
configuratorFeature: 'Manage config.lua and media.lua through SQL',
|
||||||
},
|
},
|
||||||
@@ -886,6 +893,43 @@ const adminPanelFallbackLocales = {
|
|||||||
secretConfigured: 'Secret configured · enter a replacement',
|
secretConfigured: 'Secret configured · enter a replacement',
|
||||||
invalidValue: 'Check the highlighted table or number value.',
|
invalidValue: 'Check the highlighted table or number value.',
|
||||||
saved: 'SQL configuration saved and applied.',
|
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: {
|
descriptions: {
|
||||||
featureToggle: 'Turns {name} on or off.',
|
featureToggle: 'Turns {name} on or off.',
|
||||||
boolean: 'Controls whether {name} is allowed.',
|
boolean: 'Controls whether {name} is allowed.',
|
||||||
@@ -965,10 +1009,12 @@ const adminPanelFallbackLocales = {
|
|||||||
},
|
},
|
||||||
addRow: 'Add row',
|
addRow: 'Add row',
|
||||||
addField: 'Add field',
|
addField: 'Add field',
|
||||||
|
addJob: 'Add job',
|
||||||
remove: 'Remove',
|
remove: 'Remove',
|
||||||
emptyList: 'No rows yet. Add the first row with plus.',
|
emptyList: 'No rows yet. Add the first row with plus.',
|
||||||
emptyTable: 'No fields yet. Add the first key below.',
|
emptyTable: 'No fields yet. Add the first key below.',
|
||||||
keyPlaceholder: 'New key',
|
keyPlaceholder: 'New key',
|
||||||
|
jobPlaceholder: 'Job name',
|
||||||
convertToList: 'Use list',
|
convertToList: 'Use list',
|
||||||
convertToMap: 'Use typed key table',
|
convertToMap: 'Use typed key table',
|
||||||
convertToTable: 'Use key table',
|
convertToTable: 'Use key table',
|
||||||
@@ -1155,6 +1201,8 @@ const adminPanelFallbackLocales = {
|
|||||||
change_number: 'Phone number changed',
|
change_number: 'Phone number changed',
|
||||||
factory_reset: 'Phone factory reset',
|
factory_reset: 'Phone factory reset',
|
||||||
save_configuration: 'Configuration saved',
|
save_configuration: 'Configuration saved',
|
||||||
|
create_custom_tone: 'Custom tone added',
|
||||||
|
delete_custom_tone: 'Custom tone deleted',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
errors: {
|
errors: {
|
||||||
@@ -1169,6 +1217,12 @@ const adminPanelFallbackLocales = {
|
|||||||
configurator_disabled: 'Enable the phone configurator in config.lua first.',
|
configurator_disabled: 'Enable the phone configurator in config.lua first.',
|
||||||
invalid_field: 'That configuration field is no longer available.',
|
invalid_field: 'That configuration field is no longer available.',
|
||||||
invalid_value: 'A configuration value is invalid.',
|
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.',
|
account_not_found: 'No iFruit account is linked to this phone.',
|
||||||
invalid_phone_number:
|
invalid_phone_number:
|
||||||
'Enter a phone number in the configured server format.',
|
'Enter a phone number in the configured server format.',
|
||||||
@@ -3027,6 +3081,7 @@ const defaultLocales: LocaleTree = {
|
|||||||
uninstallTitle: 'Uninstall this app?',
|
uninstallTitle: 'Uninstall this app?',
|
||||||
uninstallBody:
|
uninstallBody:
|
||||||
'{app} will be removed from this phone. You can download it again from the App Store.',
|
'{app} will be removed from this phone. You can download it again from the App Store.',
|
||||||
|
uninstallFailed: 'The app could not be uninstalled. Please try again.',
|
||||||
},
|
},
|
||||||
details: {
|
details: {
|
||||||
skyStudios: 'Sky Studios',
|
skyStudios: 'Sky Studios',
|
||||||
@@ -5683,6 +5738,8 @@ export const usePhoneStore = defineStore('phone', {
|
|||||||
state: () => ({
|
state: () => ({
|
||||||
cameraLandscape: false,
|
cameraLandscape: false,
|
||||||
currentPage: 1,
|
currentPage: 1,
|
||||||
|
customTones: cloneJsonData(EMPTY_CUSTOM_PHONE_TONES),
|
||||||
|
customTonesLoaded: false,
|
||||||
device: null as PhoneDevice | null,
|
device: null as PhoneDevice | null,
|
||||||
deviceRevisions: {} as Record<string, number>,
|
deviceRevisions: {} as Record<string, number>,
|
||||||
deviceSessionToken: null as string | null,
|
deviceSessionToken: null as string | null,
|
||||||
@@ -5726,6 +5783,36 @@ export const usePhoneStore = defineStore('phone', {
|
|||||||
this.locales = locales
|
this.locales = locales
|
||||||
this.fallbackLocales = fallbackLocales
|
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 {
|
open(payload: PhoneOpenPayload = {}): void {
|
||||||
const nextImei = payload.device?.imei ?? this.device?.imei ?? null
|
const nextImei = payload.device?.imei ?? this.device?.imei ?? null
|
||||||
const nextToken = payload.token ?? this.deviceSessionToken
|
const nextToken = payload.token ?? this.deviceSessionToken
|
||||||
@@ -5740,6 +5827,7 @@ export const usePhoneStore = defineStore('phone', {
|
|||||||
this.fallbackLocales = payload.fallbackLocales ?? defaultLocales
|
this.fallbackLocales = payload.fallbackLocales ?? defaultLocales
|
||||||
this.locales = payload.locales ?? this.fallbackLocales
|
this.locales = payload.locales ?? this.fallbackLocales
|
||||||
if (payload.device) this.hydrateDevice(payload.device)
|
if (payload.device) this.hydrateDevice(payload.device)
|
||||||
|
this.reconcileCustomTonePreferences()
|
||||||
if (payload.player) this.player = payload.player
|
if (payload.player) this.player = payload.player
|
||||||
this.security = payload.security ?? {
|
this.security = payload.security ?? {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
|
|||||||
@@ -186,3 +186,23 @@ export type AdminConfiguratorChange = {
|
|||||||
scope: 'config' | 'media'
|
scope: 'config' | 'media'
|
||||||
value: unknown
|
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'
|
||||||
|
}
|
||||||
|
|||||||
@@ -1265,8 +1265,9 @@ label.sky-list-item__row {
|
|||||||
opacity: 0;
|
opacity: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sky-field--floating-label .sky-field__select option {
|
.sky-field__select option {
|
||||||
color: var(--sky-text, #000000);
|
background: var(--sky-native-select-option-background, #ffffff);
|
||||||
|
color: var(--sky-native-select-option-text, #000000);
|
||||||
}
|
}
|
||||||
|
|
||||||
.sky-field--floating-label.sky-field--outline:not(
|
.sky-field--floating-label.sky-field--outline:not(
|
||||||
|
|||||||
@@ -164,6 +164,23 @@ describe('SkyField', () => {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('keeps native select options readable on the CEF popup surface', () => {
|
||||||
|
const controls = readFileSync(
|
||||||
|
new URL('../controls.css', import.meta.url),
|
||||||
|
'utf8',
|
||||||
|
)
|
||||||
|
const tokens = readFileSync(
|
||||||
|
new URL('../tokens.css', import.meta.url),
|
||||||
|
'utf8',
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(controls).toMatch(
|
||||||
|
/\.sky-field__select option\s*\{[^}]*background:\s*var\(--sky-native-select-option-background, #ffffff\);[^}]*color:\s*var\(--sky-native-select-option-text, #000000\);/s,
|
||||||
|
)
|
||||||
|
expect(tokens).toContain('--sky-native-select-option-background: #ffffff;')
|
||||||
|
expect(tokens).toContain('--sky-native-select-option-text: #000000;')
|
||||||
|
})
|
||||||
|
|
||||||
it('raises floating labels only after the field has a value', async () => {
|
it('raises floating labels only after the field has a value', async () => {
|
||||||
const emptyApp = createSSRApp(SkyField, {
|
const emptyApp = createSSRApp(SkyField, {
|
||||||
floatingLabel: true,
|
floatingLabel: true,
|
||||||
|
|||||||
@@ -188,6 +188,8 @@
|
|||||||
--sky-hairline: rgba(0, 0, 0, 0.2);
|
--sky-hairline: rgba(0, 0, 0, 0.2);
|
||||||
--sky-field-outline: rgba(0, 0, 0, 0.3);
|
--sky-field-outline: rgba(0, 0, 0, 0.3);
|
||||||
--sky-field-placeholder: rgba(0, 0, 0, 0.3);
|
--sky-field-placeholder: rgba(0, 0, 0, 0.3);
|
||||||
|
--sky-native-select-option-background: #ffffff;
|
||||||
|
--sky-native-select-option-text: #000000;
|
||||||
--sky-pressed: rgba(0, 0, 0, 0.1);
|
--sky-pressed: rgba(0, 0, 0, 0.1);
|
||||||
--sky-tabbar-highlight-background: rgba(0, 0, 0, 0.1);
|
--sky-tabbar-highlight-background: rgba(0, 0, 0, 0.1);
|
||||||
--sky-tabbar-thumb-background: rgba(0, 0, 0, 0.05);
|
--sky-tabbar-thumb-background: rgba(0, 0, 0, 0.05);
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
import type { AdminConfiguratorStructure } from '@/types/admin'
|
import type { AdminConfiguratorStructure } from '@/types/admin'
|
||||||
import { createMutableTableEntry } from '@/utils/adminConfiguratorDefaults'
|
import {
|
||||||
|
blankFromConfiguratorStructure,
|
||||||
|
createMutableTableEntry,
|
||||||
|
} from '@/utils/adminConfiguratorDefaults'
|
||||||
|
|
||||||
describe('admin configurator defaults', () => {
|
describe('admin configurator defaults', () => {
|
||||||
it('creates a usable company draft from its key and the server defaults', () => {
|
it('creates a usable company draft from its key and the server defaults', () => {
|
||||||
@@ -111,4 +114,65 @@ describe('admin configurator defaults', () => {
|
|||||||
false,
|
false,
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('uses the configured access value when adding radio jobs', () => {
|
||||||
|
const channelJobs: AdminConfiguratorStructure = {
|
||||||
|
entryDefault: true,
|
||||||
|
fields: {},
|
||||||
|
kind: 'table',
|
||||||
|
mutableKeys: true,
|
||||||
|
template: { kind: 'value', valueType: 'boolean' },
|
||||||
|
}
|
||||||
|
const displayNameJobs: AdminConfiguratorStructure = {
|
||||||
|
entryDefault: 0,
|
||||||
|
fields: {},
|
||||||
|
kind: 'table',
|
||||||
|
mutableKeys: true,
|
||||||
|
template: { kind: 'value', valueType: 'number' },
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(
|
||||||
|
createMutableTableEntry(
|
||||||
|
channelJobs,
|
||||||
|
'Radio.LockedChannels[1].jobs',
|
||||||
|
'mechanic',
|
||||||
|
),
|
||||||
|
).toBe(true)
|
||||||
|
expect(
|
||||||
|
createMutableTableEntry(
|
||||||
|
displayNameJobs,
|
||||||
|
'Radio.DisplayName.AllowedJobs',
|
||||||
|
'mechanic',
|
||||||
|
),
|
||||||
|
).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('preserves required nested list fields in schema-derived rows', () => {
|
||||||
|
const structure: AdminConfiguratorStructure = {
|
||||||
|
fields: {
|
||||||
|
jobs: {
|
||||||
|
fields: {
|
||||||
|
police: { kind: 'value', valueType: 'boolean' },
|
||||||
|
},
|
||||||
|
kind: 'table',
|
||||||
|
mutableKeys: true,
|
||||||
|
template: { kind: 'value', valueType: 'boolean' },
|
||||||
|
},
|
||||||
|
range: {
|
||||||
|
items: [
|
||||||
|
{ kind: 'value', valueType: 'number' },
|
||||||
|
{ kind: 'value', valueType: 'number' },
|
||||||
|
],
|
||||||
|
kind: 'list',
|
||||||
|
template: { kind: 'value', valueType: 'number' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
kind: 'table',
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(blankFromConfiguratorStructure(structure)).toEqual({
|
||||||
|
jobs: { police: false },
|
||||||
|
range: [0, 0],
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -5,10 +5,10 @@ import type {
|
|||||||
SkyPhoneAppCapability,
|
SkyPhoneAppCapability,
|
||||||
} from '@/types/apps'
|
} from '@/types/apps'
|
||||||
import type { NuiResponse } from '@/utils/nui'
|
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 STORAGE_KEY_PATTERN = /^[A-Za-z0-9._-]{1,64}$/
|
||||||
const NOTIFICATION_SOUNDS: ReadonlySet<NotificationSoundId> = new Set([
|
const NOTIFICATION_SOUNDS: ReadonlySet<BuiltInNotificationSoundId> = new Set([
|
||||||
'chime',
|
'chime',
|
||||||
'signal',
|
'signal',
|
||||||
'soft',
|
'soft',
|
||||||
@@ -24,7 +24,7 @@ type JsonResult = { success: true; value: unknown } | { success: false }
|
|||||||
export type CustomAppBridgeNotification = {
|
export type CustomAppBridgeNotification = {
|
||||||
appId: string
|
appId: string
|
||||||
route: string
|
route: string
|
||||||
sound?: NotificationSoundId
|
sound?: BuiltInNotificationSoundId
|
||||||
subtitle?: string
|
subtitle?: string
|
||||||
text: string
|
text: string
|
||||||
title: string
|
title: string
|
||||||
@@ -210,8 +210,8 @@ function normalizeNotificationPayload(
|
|||||||
const sound =
|
const sound =
|
||||||
payload.sound === undefined ||
|
payload.sound === undefined ||
|
||||||
(typeof payload.sound === 'string' &&
|
(typeof payload.sound === 'string' &&
|
||||||
NOTIFICATION_SOUNDS.has(payload.sound as NotificationSoundId))
|
NOTIFICATION_SOUNDS.has(payload.sound as BuiltInNotificationSoundId))
|
||||||
? (payload.sound as NotificationSoundId | undefined)
|
? (payload.sound as BuiltInNotificationSoundId | undefined)
|
||||||
: null
|
: null
|
||||||
if (sound === null) return null
|
if (sound === null) return null
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,156 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
import { nuiCall } from '@/utils/nui'
|
||||||
|
import { playPhoneMediaTone } from '@/utils/tones'
|
||||||
|
|
||||||
|
import {
|
||||||
|
CUSTOM_TONE_CHUNK_CHARS,
|
||||||
|
MAX_CUSTOM_TONE_PAYLOAD_CHARS,
|
||||||
|
findCustomPhoneTone,
|
||||||
|
isCustomTonePreferenceId,
|
||||||
|
parseCustomPhoneToneCatalog,
|
||||||
|
playCustomPhoneTone,
|
||||||
|
} from './customTones'
|
||||||
|
|
||||||
|
vi.mock('@/utils/nui', () => ({ nuiCall: vi.fn() }))
|
||||||
|
vi.mock('@/utils/tones', () => ({ playPhoneMediaTone: vi.fn() }))
|
||||||
|
|
||||||
|
const DISPATCH_ID = '1fbd07b4-d231-4a76-b661-c9e5d0ab19a8'
|
||||||
|
const RETRO_ID = 'a1e8db72-bc56-41c8-84e2-521180e14db1'
|
||||||
|
|
||||||
|
function tone(
|
||||||
|
id: string,
|
||||||
|
label: string,
|
||||||
|
toneType: 'notification' | 'ringtone',
|
||||||
|
byteSize = 120_000,
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
byteSize,
|
||||||
|
createdAt: '2026-08-27 12:00:00',
|
||||||
|
durationMs: 8_500,
|
||||||
|
id,
|
||||||
|
label,
|
||||||
|
mimeType: 'audio/mpeg',
|
||||||
|
toneType,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('custom phone tones', () => {
|
||||||
|
beforeEach(() => vi.clearAllMocks())
|
||||||
|
|
||||||
|
it('keeps transfer chunks below the reliable event ceiling and Base64-aligned', () => {
|
||||||
|
expect(CUSTOM_TONE_CHUNK_CHARS).toBe(8_000)
|
||||||
|
expect(CUSTOM_TONE_CHUNK_CHARS % 4).toBe(0)
|
||||||
|
expect(
|
||||||
|
Math.ceil(MAX_CUSTOM_TONE_PAYLOAD_CHARS / CUSTOM_TONE_CHUNK_CHARS),
|
||||||
|
).toBe(334)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('accepts valid stored-audio catalog entries and creates namespaced preference ids', () => {
|
||||||
|
const catalog = parseCustomPhoneToneCatalog({
|
||||||
|
notificationSounds: [tone(DISPATCH_ID, 'Dispatch Alert', 'notification')],
|
||||||
|
ringtones: [tone(RETRO_ID, 'Retro Ring', 'ringtone')],
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(catalog.ringtones[0]).toMatchObject({
|
||||||
|
id: `custom:${RETRO_ID}`,
|
||||||
|
label: 'Retro Ring',
|
||||||
|
mimeType: 'audio/mpeg',
|
||||||
|
})
|
||||||
|
expect(
|
||||||
|
findCustomPhoneTone(catalog.notificationSounds, `custom:${DISPATCH_ID}`)
|
||||||
|
?.label,
|
||||||
|
).toBe('Dispatch Alert')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects malformed ids, mismatched categories, duplicates and blank labels', () => {
|
||||||
|
const catalog = parseCustomPhoneToneCatalog({
|
||||||
|
notificationSounds: [],
|
||||||
|
ringtones: [
|
||||||
|
tone('../escape', 'Escape', 'ringtone'),
|
||||||
|
tone(DISPATCH_ID, 'Wrong category', 'notification'),
|
||||||
|
tone(RETRO_ID, ' ', 'ringtone'),
|
||||||
|
tone(DISPATCH_ID, 'Safe', 'ringtone'),
|
||||||
|
tone(DISPATCH_ID, 'Duplicate', 'ringtone'),
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(catalog.ringtones).toHaveLength(1)
|
||||||
|
expect(catalog.ringtones[0]).toMatchObject({
|
||||||
|
id: `custom:${DISPATCH_ID}`,
|
||||||
|
label: 'Safe',
|
||||||
|
})
|
||||||
|
expect(isCustomTonePreferenceId(`custom:${DISPATCH_ID}`)).toBe(true)
|
||||||
|
expect(isCustomTonePreferenceId('custom:../escape')).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('accepts safe config-file tone ids in their matching category', () => {
|
||||||
|
const catalog = parseCustomPhoneToneCatalog({
|
||||||
|
notificationSounds: [],
|
||||||
|
ringtones: [
|
||||||
|
{
|
||||||
|
...tone('config:ringtone:dispatch_call', 'Dispatch Call', 'ringtone'),
|
||||||
|
source: 'config',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(catalog.ringtones[0]).toMatchObject({
|
||||||
|
id: 'custom:config:ringtone:dispatch_call',
|
||||||
|
source: 'config',
|
||||||
|
})
|
||||||
|
expect(
|
||||||
|
isCustomTonePreferenceId('custom:config:ringtone:dispatch_call'),
|
||||||
|
).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('accepts tones above the old 270 KB limit up to 2 MB', () => {
|
||||||
|
const catalog = parseCustomPhoneToneCatalog({
|
||||||
|
notificationSounds: [],
|
||||||
|
ringtones: [
|
||||||
|
tone(DISPATCH_ID, 'Large valid tone', 'ringtone', 1_500_000),
|
||||||
|
tone(RETRO_ID, 'Too large', 'ringtone', 2_000_001),
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(catalog.ringtones).toHaveLength(1)
|
||||||
|
expect(catalog.ringtones[0]?.byteSize).toBe(1_500_000)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('loads preview audio in one NUI request before starting playback', async () => {
|
||||||
|
const customTone = parseCustomPhoneToneCatalog({
|
||||||
|
notificationSounds: [],
|
||||||
|
ringtones: [tone(DISPATCH_ID, 'Fast preview', 'ringtone')],
|
||||||
|
}).ringtones[0]!
|
||||||
|
const onStarted = vi.fn()
|
||||||
|
const stopPlayback = vi.fn()
|
||||||
|
vi.mocked(nuiCall).mockResolvedValue({
|
||||||
|
data: {
|
||||||
|
id: DISPATCH_ID,
|
||||||
|
mimeType: 'audio/ogg',
|
||||||
|
payload: 'T2dnUw==',
|
||||||
|
},
|
||||||
|
success: true,
|
||||||
|
})
|
||||||
|
vi.mocked(playPhoneMediaTone).mockImplementation(
|
||||||
|
(_url, _volume, _loop, callbacks) => {
|
||||||
|
callbacks?.onStarted?.()
|
||||||
|
return stopPlayback
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
const stop = playCustomPhoneTone(customTone, 70, false, { onStarted })
|
||||||
|
|
||||||
|
await vi.waitFor(() => expect(onStarted).toHaveBeenCalledOnce())
|
||||||
|
expect(nuiCall).toHaveBeenCalledOnce()
|
||||||
|
expect(nuiCall).toHaveBeenCalledWith('tones:audio', { id: DISPATCH_ID })
|
||||||
|
expect(playPhoneMediaTone).toHaveBeenCalledWith(
|
||||||
|
'data:audio/ogg;base64,T2dnUw==',
|
||||||
|
70,
|
||||||
|
false,
|
||||||
|
{ onStarted },
|
||||||
|
)
|
||||||
|
stop()
|
||||||
|
expect(stopPlayback).toHaveBeenCalledOnce()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,240 @@
|
|||||||
|
import { nuiCall } from '@/utils/nui'
|
||||||
|
import { playPhoneMediaTone } from '@/utils/tones'
|
||||||
|
|
||||||
|
export type CustomTonePreferenceId = `custom:${string}`
|
||||||
|
export type CustomToneType = 'notification' | 'ringtone'
|
||||||
|
|
||||||
|
export type CustomPhoneTone = {
|
||||||
|
byteSize: number
|
||||||
|
createdAt: string
|
||||||
|
durationMs: number
|
||||||
|
id: CustomTonePreferenceId
|
||||||
|
label: string
|
||||||
|
mimeType: string
|
||||||
|
source: 'config' | 'database'
|
||||||
|
toneType: CustomToneType
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CustomPhoneToneCatalog = {
|
||||||
|
notificationSounds: CustomPhoneTone[]
|
||||||
|
ringtones: CustomPhoneTone[]
|
||||||
|
}
|
||||||
|
|
||||||
|
type CustomToneAudio = {
|
||||||
|
id: string
|
||||||
|
mimeType: string
|
||||||
|
payload: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const EMPTY_CUSTOM_PHONE_TONES: CustomPhoneToneCatalog = {
|
||||||
|
notificationSounds: [],
|
||||||
|
ringtones: [],
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MAX_CUSTOM_TONE_BYTES = 2_000_000
|
||||||
|
export const MAX_CUSTOM_TONE_PAYLOAD_CHARS = 2_666_668
|
||||||
|
export const CUSTOM_TONE_CHUNK_CHARS = 8_000
|
||||||
|
|
||||||
|
const CUSTOM_TONE_ID_PATTERN =
|
||||||
|
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
||||||
|
const CONFIG_TONE_ID_PATTERN =
|
||||||
|
/^config:(ringtone|notification):[a-z0-9][a-z0-9_-]{0,47}$/
|
||||||
|
const MAX_CUSTOM_TONES_PER_TYPE = 32
|
||||||
|
const MAX_CACHED_TONES = 2
|
||||||
|
const ALLOWED_MIME_TYPES = new Set([
|
||||||
|
'audio/mpeg',
|
||||||
|
'audio/ogg',
|
||||||
|
'audio/wav',
|
||||||
|
'audio/webm',
|
||||||
|
])
|
||||||
|
const cachedSources = new Map<string, string>()
|
||||||
|
|
||||||
|
export function isCustomTonePreferenceId(
|
||||||
|
value: unknown,
|
||||||
|
): value is CustomTonePreferenceId {
|
||||||
|
return (
|
||||||
|
typeof value === 'string' &&
|
||||||
|
value.startsWith('custom:') &&
|
||||||
|
(CUSTOM_TONE_ID_PATTERN.test(value.slice('custom:'.length)) ||
|
||||||
|
CONFIG_TONE_ID_PATTERN.test(value.slice('custom:'.length)))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseToneList(
|
||||||
|
value: unknown,
|
||||||
|
expectedType: CustomToneType,
|
||||||
|
): CustomPhoneTone[] {
|
||||||
|
if (!Array.isArray(value)) return []
|
||||||
|
|
||||||
|
const tones: CustomPhoneTone[] = []
|
||||||
|
const acceptedIds = new Set<string>()
|
||||||
|
for (const entry of value.slice(0, MAX_CUSTOM_TONES_PER_TYPE)) {
|
||||||
|
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) continue
|
||||||
|
const candidate = entry as Record<string, unknown>
|
||||||
|
const source = candidate.source === 'config' ? 'config' : 'database'
|
||||||
|
const validId =
|
||||||
|
typeof candidate.id === 'string' &&
|
||||||
|
(source === 'config'
|
||||||
|
? CONFIG_TONE_ID_PATTERN.test(candidate.id) &&
|
||||||
|
candidate.id.startsWith(`config:${expectedType}:`)
|
||||||
|
: CUSTOM_TONE_ID_PATTERN.test(candidate.id))
|
||||||
|
if (
|
||||||
|
!validId ||
|
||||||
|
typeof candidate.id !== 'string' ||
|
||||||
|
acceptedIds.has(candidate.id) ||
|
||||||
|
typeof candidate.label !== 'string' ||
|
||||||
|
candidate.label.length < 1 ||
|
||||||
|
candidate.label.length > 64 ||
|
||||||
|
candidate.label.trim().length < 1 ||
|
||||||
|
candidate.toneType !== expectedType ||
|
||||||
|
typeof candidate.mimeType !== 'string' ||
|
||||||
|
!ALLOWED_MIME_TYPES.has(candidate.mimeType) ||
|
||||||
|
typeof candidate.durationMs !== 'number' ||
|
||||||
|
!Number.isInteger(candidate.durationMs) ||
|
||||||
|
candidate.durationMs < 250 ||
|
||||||
|
candidate.durationMs > 30_000 ||
|
||||||
|
typeof candidate.byteSize !== 'number' ||
|
||||||
|
!Number.isInteger(candidate.byteSize) ||
|
||||||
|
candidate.byteSize < 1 ||
|
||||||
|
candidate.byteSize > MAX_CUSTOM_TONE_BYTES ||
|
||||||
|
typeof candidate.createdAt !== 'string'
|
||||||
|
) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
acceptedIds.add(candidate.id)
|
||||||
|
tones.push({
|
||||||
|
byteSize: candidate.byteSize,
|
||||||
|
createdAt: candidate.createdAt,
|
||||||
|
durationMs: candidate.durationMs,
|
||||||
|
id: `custom:${candidate.id}`,
|
||||||
|
label: candidate.label,
|
||||||
|
mimeType: candidate.mimeType,
|
||||||
|
source,
|
||||||
|
toneType: expectedType,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return tones
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseCustomPhoneToneCatalog(
|
||||||
|
value: unknown,
|
||||||
|
): CustomPhoneToneCatalog {
|
||||||
|
const source =
|
||||||
|
value && typeof value === 'object' && !Array.isArray(value)
|
||||||
|
? (value as Record<string, unknown>)
|
||||||
|
: {}
|
||||||
|
return {
|
||||||
|
notificationSounds: parseToneList(
|
||||||
|
source.notificationSounds,
|
||||||
|
'notification',
|
||||||
|
),
|
||||||
|
ringtones: parseToneList(source.ringtones, 'ringtone'),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findCustomPhoneTone(
|
||||||
|
tones: CustomPhoneTone[],
|
||||||
|
id: string,
|
||||||
|
): CustomPhoneTone | undefined {
|
||||||
|
return isCustomTonePreferenceId(id)
|
||||||
|
? tones.find((tone) => tone.id === id)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
function cacheSource(id: string, source: string): void {
|
||||||
|
cachedSources.delete(id)
|
||||||
|
cachedSources.set(id, source)
|
||||||
|
while (cachedSources.size > MAX_CACHED_TONES) {
|
||||||
|
const oldest = cachedSources.keys().next().value
|
||||||
|
if (typeof oldest !== 'string') break
|
||||||
|
cachedSources.delete(oldest)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cacheCustomPhoneTonePayload(
|
||||||
|
id: CustomTonePreferenceId,
|
||||||
|
mimeType: string,
|
||||||
|
payload: string,
|
||||||
|
): boolean {
|
||||||
|
if (
|
||||||
|
!isCustomTonePreferenceId(id) ||
|
||||||
|
!ALLOWED_MIME_TYPES.has(mimeType) ||
|
||||||
|
payload.length < 1 ||
|
||||||
|
payload.length > MAX_CUSTOM_TONE_PAYLOAD_CHARS ||
|
||||||
|
payload.length % 4 !== 0 ||
|
||||||
|
!/^[A-Za-z0-9+/]*={0,2}$/.test(payload)
|
||||||
|
) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
cacheSource(id.slice('custom:'.length), `data:${mimeType};base64,${payload}`)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadToneSource(tone: CustomPhoneTone): Promise<string> {
|
||||||
|
const rawId = tone.id.slice('custom:'.length)
|
||||||
|
const cached = cachedSources.get(rawId)
|
||||||
|
if (cached) {
|
||||||
|
cacheSource(rawId, cached)
|
||||||
|
return cached
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await nuiCall<CustomToneAudio>('tones:audio', {
|
||||||
|
id: rawId,
|
||||||
|
})
|
||||||
|
const audio = response.data
|
||||||
|
if (
|
||||||
|
!response.success ||
|
||||||
|
!audio ||
|
||||||
|
audio.id !== rawId ||
|
||||||
|
!ALLOWED_MIME_TYPES.has(audio.mimeType) ||
|
||||||
|
typeof audio.payload !== 'string' ||
|
||||||
|
audio.payload.length < 1 ||
|
||||||
|
audio.payload.length > MAX_CUSTOM_TONE_PAYLOAD_CHARS ||
|
||||||
|
audio.payload.length % 4 !== 0 ||
|
||||||
|
!/^[A-Za-z0-9+/]*={0,2}$/.test(audio.payload)
|
||||||
|
) {
|
||||||
|
throw new Error(response.error ?? 'invalid_custom_tone')
|
||||||
|
}
|
||||||
|
|
||||||
|
const source = `data:${audio.mimeType};base64,${audio.payload}`
|
||||||
|
cacheSource(rawId, source)
|
||||||
|
return source
|
||||||
|
}
|
||||||
|
|
||||||
|
export function playCustomPhoneTone(
|
||||||
|
tone: CustomPhoneTone,
|
||||||
|
volumePercent: number,
|
||||||
|
loop: boolean,
|
||||||
|
callbacks: {
|
||||||
|
onError?: (error: unknown) => void
|
||||||
|
onStarted?: () => void
|
||||||
|
} = {},
|
||||||
|
): () => void {
|
||||||
|
let stopped = false
|
||||||
|
let stopPlayback: (() => void) | null = null
|
||||||
|
void loadToneSource(tone)
|
||||||
|
.then((source) => {
|
||||||
|
if (!stopped) {
|
||||||
|
stopPlayback = playPhoneMediaTone(
|
||||||
|
source,
|
||||||
|
volumePercent,
|
||||||
|
loop,
|
||||||
|
callbacks,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((error: unknown) => {
|
||||||
|
if (!stopped) {
|
||||||
|
console.error('[Phone audio] Failed to load custom tone', error)
|
||||||
|
callbacks.onError?.(error)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
stopped = true
|
||||||
|
stopPlayback?.()
|
||||||
|
stopPlayback = null
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -75,6 +75,12 @@ export function registerPhoneMediaElement<T extends HTMLMediaElement>(
|
|||||||
return trackPhoneMediaElement(element, true)
|
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 {
|
export function setPhoneOutputVolume(volume: number): void {
|
||||||
outputVolume = clampVolume(volume)
|
outputVolume = clampVolume(volume)
|
||||||
for (const [element, persistent] of mediaElements) {
|
for (const [element, persistent] of mediaElements) {
|
||||||
|
|||||||
@@ -123,6 +123,38 @@ describe('preferences', () => {
|
|||||||
expect(value.settings.screenBrightness).toBe(10)
|
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', () => {
|
it('keeps the phone above the minimum usable scale', () => {
|
||||||
const value = parsePhonePreferences(
|
const value = parsePhonePreferences(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
import type { LaunchablePhoneAppId } from '@/types/apps'
|
import type { LaunchablePhoneAppId } from '@/types/apps'
|
||||||
import { cloneJsonData } from '@/utils/clone'
|
import { cloneJsonData } from '@/utils/clone'
|
||||||
|
import {
|
||||||
|
isCustomTonePreferenceId,
|
||||||
|
type CustomTonePreferenceId,
|
||||||
|
} from '@/utils/customTones'
|
||||||
|
|
||||||
export const APPEARANCE_MODE_IDS = ['automatic', 'light', 'dark'] as const
|
export const APPEARANCE_MODE_IDS = ['automatic', 'light', 'dark'] as const
|
||||||
export const GRAPHICS_MODE_IDS = ['performance', 'ultimate'] 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 AppearanceMode = (typeof APPEARANCE_MODE_IDS)[number]
|
||||||
export type GraphicsMode = (typeof GRAPHICS_MODE_IDS)[number]
|
export type GraphicsMode = (typeof GRAPHICS_MODE_IDS)[number]
|
||||||
export type PhoneFrameId = (typeof PHONE_FRAME_IDS)[number]
|
export type PhoneFrameId = (typeof PHONE_FRAME_IDS)[number]
|
||||||
export type RingtoneId = (typeof RINGTONE_IDS)[number]
|
export type BuiltInRingtoneId = (typeof RINGTONE_IDS)[number]
|
||||||
export type NotificationSoundId = (typeof NOTIFICATION_SOUND_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 BuiltInWallpaperId = (typeof WALLPAPER_IDS)[number]
|
||||||
export type WallpaperId = BuiltInWallpaperId | 'custom'
|
export type WallpaperId = BuiltInWallpaperId | 'custom'
|
||||||
export type WallpaperTarget = 'home' | 'lock'
|
export type WallpaperTarget = 'home' | 'lock'
|
||||||
@@ -196,6 +204,33 @@ function readChoice<T extends string>(
|
|||||||
: fallback
|
: 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 {
|
function readWallpaperImageUrl(value: unknown): string | null {
|
||||||
if (typeof value !== 'string') return null
|
if (typeof value !== 'string') return null
|
||||||
const imageUrl = value.trim()
|
const imageUrl = value.trim()
|
||||||
@@ -328,7 +363,7 @@ export function parsePhonePreferences(raw: string | null): PhonePreferencesV1 {
|
|||||||
),
|
),
|
||||||
lockWallpaper,
|
lockWallpaper,
|
||||||
lockWallpaperImageUrl,
|
lockWallpaperImageUrl,
|
||||||
notificationSound: readChoice(
|
notificationSound: readToneChoice(
|
||||||
settings.notificationSound,
|
settings.notificationSound,
|
||||||
NOTIFICATION_SOUND_IDS,
|
NOTIFICATION_SOUND_IDS,
|
||||||
defaults.notificationSound,
|
defaults.notificationSound,
|
||||||
@@ -354,7 +389,7 @@ export function parsePhonePreferences(raw: string | null): PhonePreferencesV1 {
|
|||||||
Number.MAX_SAFE_INTEGER,
|
Number.MAX_SAFE_INTEGER,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
ringtone: readChoice(
|
ringtone: readToneChoice(
|
||||||
settings.ringtone,
|
settings.ringtone,
|
||||||
RINGTONE_IDS,
|
RINGTONE_IDS,
|
||||||
defaults.ringtone,
|
defaults.ringtone,
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
import { ALARM_SOUND_IDS } from './alarms'
|
import { ALARM_SOUND_IDS } from './alarms'
|
||||||
import { phoneToneDuration, playPhoneVibration } from './tones'
|
import {
|
||||||
|
phoneToneDuration,
|
||||||
|
playPhoneMediaTone,
|
||||||
|
playPhoneVibration,
|
||||||
|
} from './tones'
|
||||||
|
|
||||||
describe('phone tones', () => {
|
describe('phone tones', () => {
|
||||||
afterEach(() => vi.unstubAllGlobals())
|
afterEach(() => vi.unstubAllGlobals())
|
||||||
@@ -27,6 +31,8 @@ describe('phone tones', () => {
|
|||||||
pause: () => void
|
pause: () => void
|
||||||
play: () => Promise<void>
|
play: () => Promise<void>
|
||||||
preload: string
|
preload: string
|
||||||
|
load: () => void
|
||||||
|
removeAttribute: (name: string) => void
|
||||||
src: string
|
src: string
|
||||||
volume: number
|
volume: number
|
||||||
}> = []
|
}> = []
|
||||||
@@ -41,6 +47,12 @@ describe('phone tones', () => {
|
|||||||
src: string
|
src: string
|
||||||
volume = 0
|
volume = 0
|
||||||
|
|
||||||
|
load(): void {}
|
||||||
|
|
||||||
|
removeAttribute(name: string): void {
|
||||||
|
if (name === 'src') this.src = ''
|
||||||
|
}
|
||||||
|
|
||||||
constructor(src: string) {
|
constructor(src: string) {
|
||||||
super()
|
super()
|
||||||
this.src = src
|
this.src = src
|
||||||
@@ -63,4 +75,46 @@ describe('phone tones', () => {
|
|||||||
expect(players[0].currentTime).toBe(0)
|
expect(players[0].currentTime).toBe(0)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
it('reports media playback only after play has actually started', async () => {
|
||||||
|
let resolvePlay: (() => void) | undefined
|
||||||
|
const onError = vi.fn()
|
||||||
|
const onStarted = vi.fn()
|
||||||
|
vi.stubGlobal(
|
||||||
|
'Audio',
|
||||||
|
class extends EventTarget {
|
||||||
|
loop = false
|
||||||
|
preload = ''
|
||||||
|
volume = 0
|
||||||
|
|
||||||
|
load(): void {}
|
||||||
|
|
||||||
|
pause(): void {}
|
||||||
|
|
||||||
|
play(): Promise<void> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
resolvePlay = resolve
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
removeAttribute(): void {}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
const stop = playPhoneMediaTone(
|
||||||
|
'data:audio/ogg;base64,T2dnUw==',
|
||||||
|
75,
|
||||||
|
false,
|
||||||
|
{
|
||||||
|
onError,
|
||||||
|
onStarted,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(onStarted).not.toHaveBeenCalled()
|
||||||
|
resolvePlay?.()
|
||||||
|
await vi.waitFor(() => expect(onStarted).toHaveBeenCalledOnce())
|
||||||
|
expect(onError).not.toHaveBeenCalled()
|
||||||
|
stop()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
import { registerPhoneMediaElement } from '@/utils/phoneAudio'
|
import {
|
||||||
|
registerPhoneMediaElement,
|
||||||
|
unregisterPhoneMediaElement,
|
||||||
|
} from '@/utils/phoneAudio'
|
||||||
import type { AlarmSoundId } from '@/utils/alarms'
|
import type { 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'
|
export type PhoneVibrationKind = 'call' | 'notification'
|
||||||
|
|
||||||
type ToneVoice = {
|
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(
|
export function playPhoneVibration(
|
||||||
kind: PhoneVibrationKind,
|
kind: PhoneVibrationKind,
|
||||||
loop: boolean,
|
loop: boolean,
|
||||||
@@ -510,8 +551,12 @@ export function playPhoneVibration(
|
|||||||
})
|
})
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
if (stopped) return
|
||||||
stopped = true
|
stopped = true
|
||||||
player.pause()
|
player.pause()
|
||||||
player.currentTime = 0
|
player.currentTime = 0
|
||||||
|
player.removeAttribute('src')
|
||||||
|
player.load()
|
||||||
|
unregisterPhoneMediaElement(player)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -101,6 +101,10 @@ describe('AppStoreApp Sky navigation contract', () => {
|
|||||||
expect(source).toContain(
|
expect(source).toContain(
|
||||||
'appStore.uninstallApp(uninstallCandidate.value.id)',
|
'appStore.uninstallApp(uninstallCandidate.value.id)',
|
||||||
)
|
)
|
||||||
|
expect(source).toContain('@click.stop="requestUninstall(app)"')
|
||||||
|
expect(source).toContain('if (!appStore.uninstallApp(')
|
||||||
|
expect(source).toContain('Apps.appStore.account.uninstallFailed')
|
||||||
|
expect(source).toContain('role="alert"')
|
||||||
expect(source).toContain('v-if="isPhoneAppRemovable(app)"')
|
expect(source).toContain('v-if="isPhoneAppRemovable(app)"')
|
||||||
expect(source).toContain(':opened="Boolean(uninstallCandidate)"')
|
expect(source).toContain(':opened="Boolean(uninstallCandidate)"')
|
||||||
expect(source).toContain('class="store-account__grabber"')
|
expect(source).toContain('class="store-account__grabber"')
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ const openedAt = new Date()
|
|||||||
const featuredSlide = ref(0)
|
const featuredSlide = ref(0)
|
||||||
const profileOpened = ref(false)
|
const profileOpened = ref(false)
|
||||||
const uninstallCandidate = ref<LaunchablePhoneAppDefinition | null>(null)
|
const uninstallCandidate = ref<LaunchablePhoneAppDefinition | null>(null)
|
||||||
|
const uninstallError = ref('')
|
||||||
const selectedApp = ref<LaunchablePhoneAppDefinition | null>(null)
|
const selectedApp = ref<LaunchablePhoneAppDefinition | null>(null)
|
||||||
const storeScroll = ref<ComponentPublicInstance | null>(null)
|
const storeScroll = ref<ComponentPublicInstance | null>(null)
|
||||||
const featuredScroller = ref<HTMLElement | null>(null)
|
const featuredScroller = ref<HTMLElement | null>(null)
|
||||||
@@ -288,6 +289,16 @@ function closeProfile(): void {
|
|||||||
profileDragOffset.value = 0
|
profileDragOffset.value = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function requestUninstall(app: LaunchablePhoneAppDefinition): void {
|
||||||
|
uninstallError.value = ''
|
||||||
|
uninstallCandidate.value = app
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeUninstallDialog(): void {
|
||||||
|
uninstallError.value = ''
|
||||||
|
uninstallCandidate.value = null
|
||||||
|
}
|
||||||
|
|
||||||
function beginProfileDrag(event: PointerEvent): void {
|
function beginProfileDrag(event: PointerEvent): void {
|
||||||
if (!profileOpened.value || event.button !== 0) return
|
if (!profileOpened.value || event.button !== 0) return
|
||||||
profileDragPointerId = event.pointerId
|
profileDragPointerId = event.pointerId
|
||||||
@@ -318,8 +329,11 @@ function endProfileDrag(event: PointerEvent): void {
|
|||||||
|
|
||||||
function confirmUninstall(): void {
|
function confirmUninstall(): void {
|
||||||
if (!uninstallCandidate.value) return
|
if (!uninstallCandidate.value) return
|
||||||
appStore.uninstallApp(uninstallCandidate.value.id)
|
if (!appStore.uninstallApp(uninstallCandidate.value.id)) {
|
||||||
uninstallCandidate.value = null
|
uninstallError.value = phone.t('Apps.appStore.account.uninstallFailed')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
closeUninstallDialog()
|
||||||
}
|
}
|
||||||
|
|
||||||
function highlightStyle(index: number): Record<string, string> {
|
function highlightStyle(index: number): Record<string, string> {
|
||||||
@@ -1107,7 +1121,7 @@ watch(
|
|||||||
app: getPhoneAppLabel(app, phone.t),
|
app: getPhoneAppLabel(app, phone.t),
|
||||||
})
|
})
|
||||||
"
|
"
|
||||||
@click="uninstallCandidate = app"
|
@click.stop="requestUninstall(app)"
|
||||||
>
|
>
|
||||||
<Trash2 :size="17" :stroke-width="2" aria-hidden="true" />
|
<Trash2 :size="17" :stroke-width="2" aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
@@ -1119,8 +1133,8 @@ watch(
|
|||||||
<SkyDialog
|
<SkyDialog
|
||||||
:opened="Boolean(uninstallCandidate)"
|
:opened="Boolean(uninstallCandidate)"
|
||||||
role="alertdialog"
|
role="alertdialog"
|
||||||
@backdropclick="uninstallCandidate = null"
|
@backdropclick="closeUninstallDialog"
|
||||||
@escape="uninstallCandidate = null"
|
@escape="closeUninstallDialog"
|
||||||
>
|
>
|
||||||
<template #title>
|
<template #title>
|
||||||
{{ phone.t('Apps.appStore.account.uninstallTitle') }}
|
{{ phone.t('Apps.appStore.account.uninstallTitle') }}
|
||||||
@@ -1132,8 +1146,15 @@ watch(
|
|||||||
})
|
})
|
||||||
}}
|
}}
|
||||||
</p>
|
</p>
|
||||||
|
<p
|
||||||
|
v-if="uninstallError"
|
||||||
|
class="store-account__uninstall-error"
|
||||||
|
role="alert"
|
||||||
|
>
|
||||||
|
{{ uninstallError }}
|
||||||
|
</p>
|
||||||
<template #buttons>
|
<template #buttons>
|
||||||
<SkyDialogButton @click="uninstallCandidate = null">
|
<SkyDialogButton @click="closeUninstallDialog">
|
||||||
{{ phone.t('Common.cancel') }}
|
{{ phone.t('Common.cancel') }}
|
||||||
</SkyDialogButton>
|
</SkyDialogButton>
|
||||||
<SkyDialogButton strong @click="confirmUninstall">
|
<SkyDialogButton strong @click="confirmUninstall">
|
||||||
@@ -1476,6 +1497,11 @@ watch(
|
|||||||
background: var(--sky-danger-soft);
|
background: var(--sky-danger-soft);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.store-account__uninstall-error {
|
||||||
|
color: var(--sky-danger);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
.store-scroll {
|
.store-scroll {
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
|
|||||||
@@ -96,6 +96,11 @@ import {
|
|||||||
type WallpaperTarget,
|
type WallpaperTarget,
|
||||||
} from '@/utils/preferences'
|
} from '@/utils/preferences'
|
||||||
|
|
||||||
|
type ToneChoice<T extends string> = {
|
||||||
|
id: T
|
||||||
|
label: string
|
||||||
|
}
|
||||||
|
|
||||||
type SettingsView =
|
type SettingsView =
|
||||||
| 'root'
|
| 'root'
|
||||||
| 'account'
|
| 'account'
|
||||||
@@ -618,6 +623,26 @@ function selectNotificationSound(sound: NotificationSoundId): void {
|
|||||||
phone.setPreference('notificationSound', sound)
|
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 {
|
function updateAccountEmail(event: Event): void {
|
||||||
const input = event.target as HTMLInputElement
|
const input = event.target as HTMLInputElement
|
||||||
const original = input.value
|
const original = input.value
|
||||||
@@ -1276,23 +1301,25 @@ onBeforeUnmount(() => {
|
|||||||
|
|
||||||
<SkySettingsGroup :title="phone.t('Apps.settings.ringtone')">
|
<SkySettingsGroup :title="phone.t('Apps.settings.ringtone')">
|
||||||
<SkySettingsRow
|
<SkySettingsRow
|
||||||
v-for="ringtone in RINGTONE_IDS"
|
v-for="ringtone in ringtoneChoices"
|
||||||
:key="ringtone"
|
:key="ringtone.id"
|
||||||
kind="choice"
|
kind="choice"
|
||||||
:selected="phone.preferences.settings.ringtone === ringtone"
|
:selected="phone.preferences.settings.ringtone === ringtone.id"
|
||||||
:title="phone.t('Apps.settings.ringtones.' + ringtone)"
|
:title="ringtone.label"
|
||||||
@activate="selectRingtone(ringtone)"
|
@activate="selectRingtone(ringtone.id)"
|
||||||
/>
|
/>
|
||||||
</SkySettingsGroup>
|
</SkySettingsGroup>
|
||||||
|
|
||||||
<SkySettingsGroup :title="phone.t('Apps.settings.notificationSound')">
|
<SkySettingsGroup :title="phone.t('Apps.settings.notificationSound')">
|
||||||
<SkySettingsRow
|
<SkySettingsRow
|
||||||
v-for="sound in NOTIFICATION_SOUND_IDS"
|
v-for="sound in notificationSoundChoices"
|
||||||
:key="sound"
|
:key="sound.id"
|
||||||
kind="choice"
|
kind="choice"
|
||||||
:selected="phone.preferences.settings.notificationSound === sound"
|
:selected="
|
||||||
:title="phone.t('Apps.settings.notificationSoundsList.' + sound)"
|
phone.preferences.settings.notificationSound === sound.id
|
||||||
@activate="selectNotificationSound(sound)"
|
"
|
||||||
|
:title="sound.label"
|
||||||
|
@activate="selectNotificationSound(sound.id)"
|
||||||
/>
|
/>
|
||||||
</SkySettingsGroup>
|
</SkySettingsGroup>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -116,6 +116,21 @@ describe('voice provider contracts', () => {
|
|||||||
expect(phoneApp).not.toContain('callMuted = !callMuted')
|
expect(phoneApp).not.toContain('callMuted = !callMuted')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('keeps calls compatible with Yaca releases before the server status export', () => {
|
||||||
|
expect(serverVoice).toContain('is_missing_yaca_status_export')
|
||||||
|
expect(serverVoice).toContain('normalized:find("no such export", 1, true)')
|
||||||
|
expect(serverVoice).toContain('warned_about_legacy_yaca_status')
|
||||||
|
expect(serverVoice).toContain(
|
||||||
|
'using legacy compatibility because yaca-voice is started',
|
||||||
|
)
|
||||||
|
expect(serverVoice).toMatch(
|
||||||
|
/if is_missing_yaca_status_export\(enabled\) then[\s\S]*?return true/,
|
||||||
|
)
|
||||||
|
expect(serverVoice).toMatch(
|
||||||
|
/if success then\s+return enabled == true\s+end/,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
it('supports explicit automatic call-provider discovery on client and server', () => {
|
it('supports explicit automatic call-provider discovery on client and server', () => {
|
||||||
expect(config).toContain(
|
expect(config).toContain(
|
||||||
'VoiceProvider = "pma", -- auto, yaca (alias: yaca-voice)',
|
'VoiceProvider = "pma", -- auto, yaca (alias: yaca-voice)',
|
||||||
|
|||||||
@@ -540,6 +540,25 @@ function buildStructure(value, scope, path) {
|
|||||||
if (scope === 'config' && path === 'Phone.Keybind') {
|
if (scope === 'config' && path === 'Phone.Keybind') {
|
||||||
return { kind: 'optionalString' }
|
return { kind: 'optionalString' }
|
||||||
}
|
}
|
||||||
|
if (
|
||||||
|
scope === 'config' &&
|
||||||
|
/^Radio\.LockedChannels\.\d+\.jobs$/.test(path) &&
|
||||||
|
value !== null &&
|
||||||
|
typeof value === 'object' &&
|
||||||
|
!Array.isArray(value)
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
fields: Object.fromEntries(
|
||||||
|
Object.entries(value).map(([key, child]) => [
|
||||||
|
key,
|
||||||
|
buildStructure(child, scope, `${path}.${key}`),
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
kind: 'table',
|
||||||
|
mutableKeys: true,
|
||||||
|
template: { kind: 'value', valueType: 'boolean' },
|
||||||
|
}
|
||||||
|
}
|
||||||
if (
|
if (
|
||||||
scope === 'config' &&
|
scope === 'config' &&
|
||||||
path === 'Companies.Definitions' &&
|
path === 'Companies.Definitions' &&
|
||||||
@@ -723,6 +742,7 @@ function loadConfiguratorSections() {
|
|||||||
const media = mediaRoot.Media
|
const media = mediaRoot.Media
|
||||||
delete config.PhoneConfigurator
|
delete config.PhoneConfigurator
|
||||||
delete config.CommandPermissions
|
delete config.CommandPermissions
|
||||||
|
delete config.CustomTones
|
||||||
delete config.Media
|
delete config.Media
|
||||||
return [...buildSections('config', config), ...buildSections('media', media)]
|
return [...buildSections('config', config), ...buildSections('media', media)]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -73,7 +73,8 @@ describe('admin configurator fixture', () => {
|
|||||||
(root) =>
|
(root) =>
|
||||||
root !== 'Media' &&
|
root !== 'Media' &&
|
||||||
root !== 'PhoneConfigurator' &&
|
root !== 'PhoneConfigurator' &&
|
||||||
root !== 'CommandPermissions',
|
root !== 'CommandPermissions' &&
|
||||||
|
root !== 'CustomTones',
|
||||||
)
|
)
|
||||||
|
|
||||||
expect(sections).toHaveLength(46)
|
expect(sections).toHaveLength(46)
|
||||||
@@ -193,6 +194,29 @@ describe('admin configurator fixture', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('allows custom jobs in locked radio channel entries', () => {
|
||||||
|
const radio = loadConfiguratorSections()
|
||||||
|
.flatMap((section) => section.fields)
|
||||||
|
.find((field) => field.path === 'Radio')
|
||||||
|
const lockedChannels = radio?.structure?.fields?.LockedChannels
|
||||||
|
const jobs = lockedChannels?.items?.[0]?.fields?.jobs
|
||||||
|
|
||||||
|
expect(jobs).toMatchObject({
|
||||||
|
fields: {
|
||||||
|
ambulance: { kind: 'value', valueType: 'boolean' },
|
||||||
|
police: { kind: 'value', valueType: 'boolean' },
|
||||||
|
},
|
||||||
|
kind: 'table',
|
||||||
|
mutableKeys: true,
|
||||||
|
template: { kind: 'value', valueType: 'boolean' },
|
||||||
|
})
|
||||||
|
expect(lockedChannels?.template?.fields?.jobs).toMatchObject({
|
||||||
|
kind: 'table',
|
||||||
|
mutableKeys: true,
|
||||||
|
template: { kind: 'value', valueType: 'boolean' },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
it('publishes fixed schemas for every empty configurable collection', () => {
|
it('publishes fixed schemas for every empty configurable collection', () => {
|
||||||
const fields = loadConfiguratorSections().flatMap(
|
const fields = loadConfiguratorSections().flatMap(
|
||||||
(section) => section.fields,
|
(section) => section.fields,
|
||||||
|
|||||||
@@ -4933,6 +4933,13 @@ const adminMockConfigurator = {
|
|||||||
sections: loadConfiguratorSections(),
|
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) => {
|
app.post('/api/:endpoint', async (request, response, next) => {
|
||||||
const endpoint = request.params.endpoint
|
const endpoint = request.params.endpoint
|
||||||
const loggedBody = { ...request.body }
|
const loggedBody = { ...request.body }
|
||||||
@@ -4954,6 +4961,9 @@ app.post('/api/:endpoint', async (request, response, next) => {
|
|||||||
if (endpoint === 'memos:devCapture') {
|
if (endpoint === 'memos:devCapture') {
|
||||||
loggedBody.audioDataUrl = `<${String(request.body.audioDataUrl ?? '').length} characters>`
|
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)
|
console.log('[NUI]', endpoint, loggedBody)
|
||||||
if (endpoint === 'music:bootstrap') {
|
if (endpoint === 'music:bootstrap') {
|
||||||
response.json({ success: true, data: musicBootstrap() })
|
response.json({ success: true, data: musicBootstrap() })
|
||||||
@@ -4967,6 +4977,87 @@ app.post('/api/:endpoint', async (request, response, next) => {
|
|||||||
response.json({ success: true, data: adminMockConfigurator })
|
response.json({ success: true, data: adminMockConfigurator })
|
||||||
return
|
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') {
|
if (endpoint === 'admin:save-configurator') {
|
||||||
const changes = Array.isArray(request.body.changes)
|
const changes = Array.isArray(request.body.changes)
|
||||||
? request.body.changes
|
? request.body.changes
|
||||||
|
|||||||
@@ -61,6 +61,33 @@ Config.Phone = {
|
|||||||
DeviceName = "iFruit 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
|
-- 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.
|
-- from every phone, the App Store and per-device app management.
|
||||||
Config.Apps = {
|
Config.Apps = {
|
||||||
@@ -216,8 +243,9 @@ Config.Radio = {
|
|||||||
AutoRejoin = false,
|
AutoRejoin = false,
|
||||||
DisplayName = {
|
DisplayName = {
|
||||||
Enabled = true,
|
Enabled = true,
|
||||||
|
AllowEveryone = false, -- true allows every job; false uses AllowedJobs and its minimum grades
|
||||||
MaxLength = 32,
|
MaxLength = 32,
|
||||||
AllowedJobs = { -- Job name = minimum grade. Unlisted jobs cannot set a radio display name.
|
AllowedJobs = { -- Job name = minimum grade. Used when AllowEveryone is false.
|
||||||
police = 0,
|
police = 0,
|
||||||
sheriff = 0,
|
sheriff = 0,
|
||||||
fib = 0,
|
fib = 0,
|
||||||
@@ -450,8 +478,8 @@ Config.Garage = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Config.Housing = {
|
Config.Housing = {
|
||||||
System = "auto", -- auto, rtx, quasar, vms, rx, nolag, sn, esx_property, qbx_properties
|
System = "auto", -- auto, rtx, quasar, tgiann, vms, rx, nolag, sn, esx_property, qbx_properties
|
||||||
AutoPriority = { "esx_property", "qbx_properties", "rtx", "quasar", "vms", "rx", "nolag", "sn" },
|
AutoPriority = { "esx_property", "qbx_properties", "rtx", "quasar", "tgiann", "vms", "rx", "nolag", "sn" },
|
||||||
MaximumProperties = 50,
|
MaximumProperties = 50,
|
||||||
OverviewRequestsPerMinute = 30,
|
OverviewRequestsPerMinute = 30,
|
||||||
ActionsPerMinute = 12,
|
ActionsPerMinute = 12,
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -204,8 +204,8 @@ Locales["de"] = {
|
|||||||
},
|
},
|
||||||
AdminPanel = {
|
AdminPanel = {
|
||||||
name = "Phone Admin", subtitle = "Administration", navigation = "Admin-Navigation", refresh = "Admin-Daten aktualisieren", loading = "Geschützte Daten werden geladen...",
|
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" },
|
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", auditFeature = "Sensible Admin-Aktionen prüfen", configuratorFeature = "config.lua und media.lua über SQL verwalten" },
|
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" },
|
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" } } },
|
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." },
|
players = { eyebrow = "Aktive Sitzungen", title = "Online-Spieler", online = "Jetzt online", empty = "Keine Spieler gefunden", emptyBody = "Passe die Suche an oder aktualisiere die Spielerliste." },
|
||||||
@@ -2022,10 +2022,11 @@ Locales["de"] = {
|
|||||||
},
|
},
|
||||||
account = {
|
account = {
|
||||||
account = "Account", title = "App-Verwaltung", skyAccount = "Sky Phone Konto",
|
account = "Account", title = "App-Verwaltung", skyAccount = "Sky Phone Konto",
|
||||||
apps = "Installation von Apps", games = "Spiele", library = "Deine Bibliothek.", myApps = "Meine Apps",
|
apps = "Installierte Apps", games = "Spiele", library = "Deine Bibliothek.", myApps = "Meine Apps",
|
||||||
downloadedOn = "Gespeichert {date}", uninstall = "Deinstallieren", uninstallApp = "Deinstallieren {app}",
|
downloadedOn = "Gespeichert {date}", uninstall = "Deinstallieren", uninstallApp = "Deinstallieren {app}",
|
||||||
uninstallTitle = "Diese App deinstallieren?",
|
uninstallTitle = "Diese App deinstallieren?",
|
||||||
uninstallBody = "{app} wird von diesem Handy entfernt. Du kannst die App erneut aus dem App Store laden.",
|
uninstallBody = "{app} wird von diesem Handy entfernt. Du kannst die App erneut aus dem App Store laden.",
|
||||||
|
uninstallFailed = "Die App konnte nicht deinstalliert werden. Bitte versuche es erneut.",
|
||||||
},
|
},
|
||||||
details = {
|
details = {
|
||||||
skyStudios = "Sky Studios", share = "App teilen", openDetails = "Ansicht {app}",
|
skyStudios = "Sky Studios", share = "App teilen", openDetails = "Ansicht {app}",
|
||||||
@@ -2140,6 +2141,8 @@ Locales["de"] = {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Locales["de"].Nui.AdminPanel.configurator.table.addJob = "Job hinzufügen"
|
||||||
|
Locales["de"].Nui.AdminPanel.configurator.table.jobPlaceholder = "Jobname"
|
||||||
Locales["de"].Nui.AdminPanel.configurator.table.subtabs = {
|
Locales["de"].Nui.AdminPanel.configurator.table.subtabs = {
|
||||||
Dictionaries = "Animationsdateien",
|
Dictionaries = "Animationsdateien",
|
||||||
Clips = "Clips",
|
Clips = "Clips",
|
||||||
@@ -2177,3 +2180,46 @@ Locales["de"].Nui.AdminPanel.configurator.table.subtabs = {
|
|||||||
AllowedJobs = "Erlaubte Jobs",
|
AllowedJobs = "Erlaubte Jobs",
|
||||||
Websites = "Webseiten",
|
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"
|
||||||
|
|||||||
@@ -204,8 +204,8 @@ Locales["en"] = {
|
|||||||
},
|
},
|
||||||
AdminPanel = {
|
AdminPanel = {
|
||||||
name = "Phone Admin", subtitle = "Administration", navigation = "Admin navigation", refresh = "Refresh admin data", loading = "Loading protected data...",
|
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" },
|
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", auditFeature = "Review sensitive admin actions", configuratorFeature = "Manage config.lua and media.lua through SQL" },
|
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" },
|
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" } } },
|
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." },
|
players = { eyebrow = "Active sessions", title = "Online players", online = "Online now", empty = "No players found", emptyBody = "Adjust the search or refresh the live player list." },
|
||||||
@@ -2026,6 +2026,7 @@ Locales["en"] = {
|
|||||||
downloadedOn = "Downloaded {date}", uninstall = "Uninstall", uninstallApp = "Uninstall {app}",
|
downloadedOn = "Downloaded {date}", uninstall = "Uninstall", uninstallApp = "Uninstall {app}",
|
||||||
uninstallTitle = "Uninstall this app?",
|
uninstallTitle = "Uninstall this app?",
|
||||||
uninstallBody = "{app} will be removed from this phone. You can download it again from the App Store.",
|
uninstallBody = "{app} will be removed from this phone. You can download it again from the App Store.",
|
||||||
|
uninstallFailed = "The app could not be uninstalled. Please try again.",
|
||||||
},
|
},
|
||||||
details = {
|
details = {
|
||||||
skyStudios = "Sky Studios", share = "Share app", openDetails = "View {app}",
|
skyStudios = "Sky Studios", share = "Share app", openDetails = "View {app}",
|
||||||
@@ -2140,6 +2141,8 @@ Locales["en"] = {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Locales["en"].Nui.AdminPanel.configurator.table.addJob = "Add job"
|
||||||
|
Locales["en"].Nui.AdminPanel.configurator.table.jobPlaceholder = "Job name"
|
||||||
Locales["en"].Nui.AdminPanel.configurator.table.subtabs = {
|
Locales["en"].Nui.AdminPanel.configurator.table.subtabs = {
|
||||||
Dictionaries = "Dictionaries",
|
Dictionaries = "Dictionaries",
|
||||||
Clips = "Clips",
|
Clips = "Clips",
|
||||||
@@ -2177,3 +2180,46 @@ Locales["en"].Nui.AdminPanel.configurator.table.subtabs = {
|
|||||||
AllowedJobs = "Allowed Jobs",
|
AllowedJobs = "Allowed Jobs",
|
||||||
Websites = "Websites",
|
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"
|
||||||
|
|||||||
@@ -204,8 +204,8 @@ Locales["es"] = {
|
|||||||
},
|
},
|
||||||
AdminPanel = {
|
AdminPanel = {
|
||||||
name = "Administrador de teléfono", subtitle = "Administración", navigation = "Navegación del administrador", refresh = "Actualizar los datos del administrador", loading = "Cargar datos protegidos...",
|
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" },
|
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", auditFeature = "Revisar las acciones del administrador sensibles", configuratorFeature = "Administrar config.lua y media.lua a través de SQL" },
|
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" },
|
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" } } },
|
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." },
|
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." },
|
||||||
@@ -2026,6 +2026,7 @@ Locales["es"] = {
|
|||||||
downloadedOn = "Descargado {date}", uninstall = "Desinstalar", uninstallApp = "Desinstalar {app}",
|
downloadedOn = "Descargado {date}", uninstall = "Desinstalar", uninstallApp = "Desinstalar {app}",
|
||||||
uninstallTitle = "¿Desinstalar esta aplicación?",
|
uninstallTitle = "¿Desinstalar esta aplicación?",
|
||||||
uninstallBody = "{app} será eliminado de este teléfono. Puedes descargarlo de nuevo de la App Store.",
|
uninstallBody = "{app} será eliminado de este teléfono. Puedes descargarlo de nuevo de la App Store.",
|
||||||
|
uninstallFailed = "No se ha podido desinstalar la aplicación. Inténtalo de nuevo.",
|
||||||
},
|
},
|
||||||
details = {
|
details = {
|
||||||
skyStudios = "Sky Studios", share = "Compartir aplicación", openDetails = "Ver {app}",
|
skyStudios = "Sky Studios", share = "Compartir aplicación", openDetails = "Ver {app}",
|
||||||
@@ -2140,6 +2141,8 @@ Locales["es"] = {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Locales["es"].Nui.AdminPanel.configurator.table.addJob = "Añadir trabajo"
|
||||||
|
Locales["es"].Nui.AdminPanel.configurator.table.jobPlaceholder = "Nombre del trabajo"
|
||||||
Locales["es"].Nui.AdminPanel.configurator.table.subtabs = {
|
Locales["es"].Nui.AdminPanel.configurator.table.subtabs = {
|
||||||
Dictionaries = "Diccionarios",
|
Dictionaries = "Diccionarios",
|
||||||
Clips = "Los clips",
|
Clips = "Los clips",
|
||||||
@@ -2177,3 +2180,46 @@ Locales["es"].Nui.AdminPanel.configurator.table.subtabs = {
|
|||||||
AllowedJobs = "Trabajos permitidos",
|
AllowedJobs = "Trabajos permitidos",
|
||||||
Websites = "Las páginas web",
|
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"
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ use_experimental_fxv2_oal 'yes'
|
|||||||
|
|
||||||
author 'Sky-Systems'
|
author 'Sky-Systems'
|
||||||
description 'Sky Phone'
|
description 'Sky Phone'
|
||||||
version '0.3.0'
|
version '0.3.3'
|
||||||
|
|
||||||
provide 'lb-phone'
|
provide 'lb-phone'
|
||||||
provide '17mov_Phone'
|
provide '17mov_Phone'
|
||||||
@@ -52,6 +52,7 @@ client_scripts {
|
|||||||
'source/bridge/client/radio.lua',
|
'source/bridge/client/radio.lua',
|
||||||
'source/client/payphones.lua',
|
'source/client/payphones.lua',
|
||||||
'source/client/custom_apps.lua',
|
'source/client/custom_apps.lua',
|
||||||
|
'source/client/custom_tones.lua',
|
||||||
'source/client/nui_server_bridge.lua',
|
'source/client/nui_server_bridge.lua',
|
||||||
'source/client/nui_events.lua',
|
'source/client/nui_events.lua',
|
||||||
'source/client/notifications.lua',
|
'source/client/notifications.lua',
|
||||||
@@ -105,6 +106,7 @@ server_scripts {
|
|||||||
'source/server/phone.lua',
|
'source/server/phone.lua',
|
||||||
'source/server/device_directory.lua',
|
'source/server/device_directory.lua',
|
||||||
'source/server/db_migrate.lua',
|
'source/server/db_migrate.lua',
|
||||||
|
'source/server/custom_tones.lua',
|
||||||
'source/server/admin.lua',
|
'source/server/admin.lua',
|
||||||
'source/server/lb_phone_migration.lua',
|
'source/server/lb_phone_migration.lua',
|
||||||
'source/server/custom_app_storage.lua',
|
'source/server/custom_app_storage.lua',
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
local provider_name = "tgiann"
|
||||||
|
local resource_name = "tgiann-house"
|
||||||
|
|
||||||
|
local function house_reference(value)
|
||||||
|
if type(value) ~= "string" then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
return value ~= "" and value or nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local function house_entrance(house)
|
||||||
|
local success, house_data = pcall(function()
|
||||||
|
return exports[resource_name]:getHouseData(house)
|
||||||
|
end)
|
||||||
|
if not success then
|
||||||
|
Bridge.Debug(
|
||||||
|
"error",
|
||||||
|
"[sky_phone] tgiann-house:getHouseData failed for '%s': %s",
|
||||||
|
house,
|
||||||
|
tostring(house_data)
|
||||||
|
)
|
||||||
|
return nil, "provider_error"
|
||||||
|
end
|
||||||
|
local entrance = type(house_data) == "table"
|
||||||
|
and Bridge.Normalize.Coordinates(house_data.doorCoord) or nil
|
||||||
|
if not entrance then
|
||||||
|
return nil, "invalid_coordinates"
|
||||||
|
end
|
||||||
|
return entrance
|
||||||
|
end
|
||||||
|
|
||||||
|
local function enrich_overview(properties)
|
||||||
|
local result = {}
|
||||||
|
if GetResourceState(resource_name) ~= "started" or type(properties) ~= "table" then
|
||||||
|
return result
|
||||||
|
end
|
||||||
|
|
||||||
|
for _, property in ipairs(properties) do
|
||||||
|
local house = type(property) == "table" and house_reference(property.providerId) or nil
|
||||||
|
if house and property.id == provider_name .. ":" .. house then
|
||||||
|
local entrance = house_entrance(house)
|
||||||
|
if entrance then
|
||||||
|
result[#result + 1] = {
|
||||||
|
id = property.id,
|
||||||
|
entrance = entrance,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return result
|
||||||
|
end
|
||||||
|
|
||||||
|
Bridge.Housing.RegisterClientProvider(provider_name, {
|
||||||
|
enrich_overview = enrich_overview,
|
||||||
|
execute = function(action, data)
|
||||||
|
if GetResourceState(resource_name) ~= "started" then
|
||||||
|
return false, "provider_unavailable"
|
||||||
|
end
|
||||||
|
if action ~= "set_waypoint" then
|
||||||
|
return false, "capability_unavailable"
|
||||||
|
end
|
||||||
|
local house = type(data) == "table" and house_reference(data.providerId) or nil
|
||||||
|
if not house then
|
||||||
|
return false, "invalid_property"
|
||||||
|
end
|
||||||
|
local entrance, error_code = house_entrance(house)
|
||||||
|
if not entrance then
|
||||||
|
return false, error_code
|
||||||
|
end
|
||||||
|
SetNewWaypoint(entrance.x + 0.0, entrance.y + 0.0)
|
||||||
|
return true
|
||||||
|
end,
|
||||||
|
})
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
local provider_name = "tgiann"
|
||||||
|
local resource_name = "tgiann-house"
|
||||||
|
|
||||||
|
local function query_owned_houses(identifier)
|
||||||
|
local success, properties = pcall(function()
|
||||||
|
return MySQL.query.await([[
|
||||||
|
SELECT `name`
|
||||||
|
FROM `tgiann_house`
|
||||||
|
WHERE `owner` = ?
|
||||||
|
ORDER BY `name` ASC
|
||||||
|
]], { identifier })
|
||||||
|
end)
|
||||||
|
if not success or type(properties) ~= "table" then
|
||||||
|
Bridge.Debug("error", "[sky_phone] tgiann-house overview query failed: %s", tostring(properties))
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
return properties
|
||||||
|
end
|
||||||
|
|
||||||
|
local function owns_house(identifier, house)
|
||||||
|
local success, property = pcall(function()
|
||||||
|
return MySQL.single.await([[
|
||||||
|
SELECT `name`
|
||||||
|
FROM `tgiann_house`
|
||||||
|
WHERE `name` = ? AND `owner` = ?
|
||||||
|
LIMIT 1
|
||||||
|
]], { house, identifier })
|
||||||
|
end)
|
||||||
|
if not success then
|
||||||
|
Bridge.Debug("error", "[sky_phone] tgiann-house ownership query failed: %s", tostring(property))
|
||||||
|
return nil, "provider_error"
|
||||||
|
end
|
||||||
|
return type(property) == "table"
|
||||||
|
end
|
||||||
|
|
||||||
|
local function house_from_property_id(value)
|
||||||
|
if type(value) ~= "string" then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
local house = value:match("^tgiann:(.+)$")
|
||||||
|
return house and house ~= "" and house or nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local function normalized_property(house)
|
||||||
|
return {
|
||||||
|
id = provider_name .. ":" .. house,
|
||||||
|
providerId = house,
|
||||||
|
name = house,
|
||||||
|
access = "owner",
|
||||||
|
locked = false,
|
||||||
|
capabilities = {
|
||||||
|
lock = false,
|
||||||
|
keys = false,
|
||||||
|
waypoint = true,
|
||||||
|
cctv = false,
|
||||||
|
garageStatus = false,
|
||||||
|
},
|
||||||
|
cctv = { enabled = false },
|
||||||
|
garage = nil,
|
||||||
|
keys = nil,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
Bridge.Housing.RegisterProvider(provider_name, {
|
||||||
|
resource_name = resource_name,
|
||||||
|
is_available = function()
|
||||||
|
return GetResourceState(resource_name) == "started"
|
||||||
|
end,
|
||||||
|
get_overview = function(source)
|
||||||
|
local identifier = Bridge.Framework.GetIdentifier(source)
|
||||||
|
if not identifier then
|
||||||
|
return nil, "housing_unavailable"
|
||||||
|
end
|
||||||
|
local properties = query_owned_houses(identifier)
|
||||||
|
if not properties then
|
||||||
|
return nil, "provider_error"
|
||||||
|
end
|
||||||
|
|
||||||
|
local result = {}
|
||||||
|
local seen = {}
|
||||||
|
local maximum = math.max(0, math.floor(tonumber(Config.Housing.MaximumProperties) or 0))
|
||||||
|
for _, property in ipairs(properties) do
|
||||||
|
if #result >= maximum then
|
||||||
|
break
|
||||||
|
end
|
||||||
|
local house = type(property.name) == "string" and property.name or nil
|
||||||
|
if house and house ~= "" and not seen[house] then
|
||||||
|
seen[house] = true
|
||||||
|
result[#result + 1] = normalized_property(house)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return result
|
||||||
|
end,
|
||||||
|
prepare = function(source, action, data)
|
||||||
|
if action == "toggle_lock" or action == "grant_key" or action == "revoke_key"
|
||||||
|
or action == "key_candidates"
|
||||||
|
then
|
||||||
|
return nil, "capability_unavailable"
|
||||||
|
end
|
||||||
|
if action == "open_cctv" then
|
||||||
|
return nil, "cctv_unavailable"
|
||||||
|
end
|
||||||
|
if action ~= "set_waypoint" then
|
||||||
|
return nil, "invalid_action"
|
||||||
|
end
|
||||||
|
|
||||||
|
local house = house_from_property_id(data and data.propertyId)
|
||||||
|
if not house then
|
||||||
|
return nil, "invalid_property"
|
||||||
|
end
|
||||||
|
local identifier = Bridge.Framework.GetIdentifier(source)
|
||||||
|
if not identifier then
|
||||||
|
return nil, "housing_unavailable"
|
||||||
|
end
|
||||||
|
local owned, error_code = owns_house(identifier, house)
|
||||||
|
if owned == nil then
|
||||||
|
return nil, error_code
|
||||||
|
end
|
||||||
|
if not owned then
|
||||||
|
return nil, "property_access_denied"
|
||||||
|
end
|
||||||
|
return { providerId = house }
|
||||||
|
end,
|
||||||
|
})
|
||||||
@@ -18,6 +18,13 @@ local radio_provider_aliases = {
|
|||||||
["pma-voice"] = "pma",
|
["pma-voice"] = "pma",
|
||||||
salty = "saltychat",
|
salty = "saltychat",
|
||||||
}
|
}
|
||||||
|
local warned_about_legacy_yaca_status = false
|
||||||
|
|
||||||
|
local function is_missing_yaca_status_export(error_message)
|
||||||
|
local normalized = tostring(error_message):lower()
|
||||||
|
return normalized:find("isenabled", 1, true) ~= nil
|
||||||
|
and normalized:find("no such export", 1, true) ~= nil
|
||||||
|
end
|
||||||
|
|
||||||
local function yaca_is_enabled()
|
local function yaca_is_enabled()
|
||||||
if GetResourceState("yaca-voice") ~= "started" then
|
if GetResourceState("yaca-voice") ~= "started" then
|
||||||
@@ -27,7 +34,21 @@ local function yaca_is_enabled()
|
|||||||
local success, enabled = pcall(function()
|
local success, enabled = pcall(function()
|
||||||
return exports["yaca-voice"]:isEnabled()
|
return exports["yaca-voice"]:isEnabled()
|
||||||
end)
|
end)
|
||||||
if not success then
|
if success then
|
||||||
|
return enabled == true
|
||||||
|
end
|
||||||
|
if is_missing_yaca_status_export(enabled) then
|
||||||
|
if not warned_about_legacy_yaca_status then
|
||||||
|
warned_about_legacy_yaca_status = true
|
||||||
|
Bridge.Debug(
|
||||||
|
"warn",
|
||||||
|
"[sky_phone] Yaca does not expose the server isEnabled status; using legacy compatibility because yaca-voice is started.",
|
||||||
|
{ always = true }
|
||||||
|
)
|
||||||
|
end
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
Bridge.Debug(
|
Bridge.Debug(
|
||||||
"error",
|
"error",
|
||||||
"[sky_phone] Yaca could not report its availability: %s",
|
"[sky_phone] Yaca could not report its availability: %s",
|
||||||
@@ -35,8 +56,6 @@ local function yaca_is_enabled()
|
|||||||
{ always = true }
|
{ always = true }
|
||||||
)
|
)
|
||||||
return false
|
return false
|
||||||
end
|
|
||||||
return enabled == true
|
|
||||||
end
|
end
|
||||||
|
|
||||||
local function resolve_call_provider()
|
local function resolve_call_provider()
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
SkyPhoneFocus = {}
|
SkyPhoneFocus = {}
|
||||||
|
|
||||||
local blocked_phone_controls = { 24, 140, 141, 142, 257, 263, 264 }
|
local blocked_phone_controls = { 24, 140, 141, 142, 199, 200, 257, 263, 264 }
|
||||||
local blocked_phone_look_controls = { 1, 2, 3, 4, 5, 6 }
|
local blocked_phone_look_controls = { 1, 2, 3, 4, 5, 6 }
|
||||||
local focused_control_groups = { 0, 1, 2 }
|
local focused_control_groups = { 0, 1, 2 }
|
||||||
local hold_to_look_enabled = false
|
local hold_to_look_enabled = false
|
||||||
|
|||||||
@@ -99,6 +99,22 @@ local function send_admin_panel_open()
|
|||||||
})
|
})
|
||||||
end
|
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()
|
local function close_admin_panel()
|
||||||
if not admin_panel_open then
|
if not admin_panel_open then
|
||||||
return
|
return
|
||||||
@@ -147,6 +163,7 @@ AddEventHandler("sky_phone:configurator:updated", function()
|
|||||||
refresh_phone_key_mapping()
|
refresh_phone_key_mapping()
|
||||||
refresh_test_data_command_suggestion()
|
refresh_test_data_command_suggestion()
|
||||||
refresh_admin_command_suggestion()
|
refresh_admin_command_suggestion()
|
||||||
|
send_phone_tone_catalog()
|
||||||
SkyPhoneApps.SendCatalog()
|
SkyPhoneApps.SendCatalog()
|
||||||
if is_open and device_payload then
|
if is_open and device_payload then
|
||||||
apply_disabled_apps(device_payload)
|
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.
|
-- cannot survive unless their UI is replayed as part of this handshake.
|
||||||
SkyPhoneFocus.BeginNuiHydration()
|
SkyPhoneFocus.BeginNuiHydration()
|
||||||
Bridge.Debug("debug", "[sky_phone] NUI reported ready.", { always = true })
|
Bridge.Debug("debug", "[sky_phone] NUI reported ready.", { always = true })
|
||||||
|
send_phone_tone_catalog()
|
||||||
SkyPhoneApps.SendCatalog()
|
SkyPhoneApps.SendCatalog()
|
||||||
if open_requested and device_payload then
|
if open_requested and device_payload then
|
||||||
send_open_message()
|
send_open_message()
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ local callback_groups = {
|
|||||||
admin = [[
|
admin = [[
|
||||||
bootstrap player save-apps reveal-password activity
|
bootstrap player save-apps reveal-password activity
|
||||||
reset-passcode change-number factory-reset configurator save-configurator
|
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]],
|
banking = [[overview transfer]],
|
||||||
billing = [[overview list detail markRead pay dispute]],
|
billing = [[overview list detail markRead pay dispute]],
|
||||||
@@ -71,6 +72,7 @@ local callback_groups = {
|
|||||||
]],
|
]],
|
||||||
security = [[unlock set-passcode change-passcode disable-passcode]],
|
security = [[unlock set-passcode change-passcode disable-passcode]],
|
||||||
sim = [[insert eject]],
|
sim = [[insert eject]],
|
||||||
|
tones = [[list]],
|
||||||
["weazel-news"] = [[context list get manage-list create update delete]],
|
["weazel-news"] = [[context list get manage-list create update delete]],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -950,4 +950,114 @@ Bridge.Callbacks.Register("sky_phone:admin:save-configurator", function(source,
|
|||||||
end
|
end
|
||||||
return response
|
return response
|
||||||
end)
|
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)
|
end)
|
||||||
|
|||||||
@@ -6,7 +6,12 @@ local exchange_lock = false
|
|||||||
local markets = {}
|
local markets = {}
|
||||||
local market_order = {}
|
local market_order = {}
|
||||||
local market_dynamics = {}
|
local market_dynamics = {}
|
||||||
|
local market_state = {}
|
||||||
|
local market_history = {}
|
||||||
|
local market_daily_buckets = {}
|
||||||
local market_cursor = 1
|
local market_cursor = 1
|
||||||
|
local market_daily_bucket_seconds = 5 * 60
|
||||||
|
local market_persistence_interval = market_daily_bucket_seconds * 1000
|
||||||
local global_market_trend = 0
|
local global_market_trend = 0
|
||||||
local global_market_cycle = {
|
local global_market_cycle = {
|
||||||
direction = 0,
|
direction = 0,
|
||||||
@@ -370,6 +375,144 @@ local function initialize_markets()
|
|||||||
market_cursor = math.min(market_cursor, math.max(#market_order, 1))
|
market_cursor = math.min(market_cursor, math.max(#market_order, 1))
|
||||||
end
|
end
|
||||||
|
|
||||||
|
local function add_market_daily_price(buckets, price, timestamp)
|
||||||
|
local bucket_id = math.floor(timestamp / market_daily_bucket_seconds)
|
||||||
|
local bucket = buckets[#buckets]
|
||||||
|
if bucket and bucket.bucket_id == bucket_id then
|
||||||
|
bucket.low = math.min(bucket.low, price)
|
||||||
|
bucket.high = math.max(bucket.high, price)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
buckets[#buckets + 1] = {
|
||||||
|
bucket_id = bucket_id,
|
||||||
|
low = price,
|
||||||
|
high = price,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
local function market_daily_range(market_id, price, timestamp)
|
||||||
|
local buckets = market_daily_buckets[market_id]
|
||||||
|
local cutoff_bucket = math.floor((timestamp - 24 * 60 * 60) / market_daily_bucket_seconds)
|
||||||
|
while buckets[1] and buckets[1].bucket_id < cutoff_bucket do
|
||||||
|
table.remove(buckets, 1)
|
||||||
|
end
|
||||||
|
local low = price
|
||||||
|
local high = price
|
||||||
|
for index = 1, #buckets do
|
||||||
|
local bucket = buckets[index]
|
||||||
|
low = math.min(low, bucket.low)
|
||||||
|
high = math.max(high, bucket.high)
|
||||||
|
end
|
||||||
|
return low, high
|
||||||
|
end
|
||||||
|
|
||||||
|
local function load_market_cache()
|
||||||
|
local rows = Bridge.Database.Query([[
|
||||||
|
SELECT `id`,`price`,`version`,`status`, UNIX_TIMESTAMP(`updated_at`) AS `updated_at`
|
||||||
|
FROM `sky_phone_crypto_markets`
|
||||||
|
]], {})
|
||||||
|
local next_market_state = {}
|
||||||
|
local next_market_history = {}
|
||||||
|
local next_market_daily_buckets = {}
|
||||||
|
local history_limit = math.min(Config.Crypto.HistoryRetentionTicks, Config.Crypto.SparklinePoints)
|
||||||
|
local timestamp = os.time()
|
||||||
|
for _, row in ipairs(rows) do
|
||||||
|
if markets[row.id] then
|
||||||
|
next_market_state[row.id] = {
|
||||||
|
price = tonumber(row.price) or markets[row.id].InitialPrice,
|
||||||
|
version = tonumber(row.version) or 1,
|
||||||
|
status = row.status,
|
||||||
|
updated_at = tonumber(row.updated_at) or timestamp,
|
||||||
|
dirty = false,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
for _, market_id in ipairs(market_order) do
|
||||||
|
local state = next_market_state[market_id]
|
||||||
|
if not state then
|
||||||
|
error(("[sky_phone] Crypto market state is missing after initialization: %s"):format(market_id))
|
||||||
|
end
|
||||||
|
local rows_for_market = Bridge.Database.Query([[
|
||||||
|
SELECT `price`
|
||||||
|
FROM `sky_phone_crypto_market_ticks`
|
||||||
|
WHERE `market_id` = ? ORDER BY `created_at` DESC, `id` DESC LIMIT ?
|
||||||
|
]], { market_id, history_limit })
|
||||||
|
local history = {}
|
||||||
|
for index = #rows_for_market, 1, -1 do
|
||||||
|
history[#history + 1] = tonumber(rows_for_market[index].price) or state.price
|
||||||
|
end
|
||||||
|
if #history == 0 then
|
||||||
|
history[1] = state.price
|
||||||
|
end
|
||||||
|
next_market_history[market_id] = history
|
||||||
|
local daily_rows = Bridge.Database.Query([[
|
||||||
|
SELECT FLOOR(UNIX_TIMESTAMP(`created_at`) / ?) AS `bucket_id`,
|
||||||
|
MIN(`price`) AS `low_price`, MAX(`price`) AS `high_price`
|
||||||
|
FROM `sky_phone_crypto_market_ticks`
|
||||||
|
WHERE `market_id` = ?
|
||||||
|
AND `created_at` >= DATE_SUB(CURRENT_TIMESTAMP, INTERVAL 24 HOUR)
|
||||||
|
GROUP BY `bucket_id` ORDER BY `bucket_id`
|
||||||
|
]], { market_daily_bucket_seconds, market_id })
|
||||||
|
local daily_buckets = {}
|
||||||
|
for _, daily_row in ipairs(daily_rows) do
|
||||||
|
daily_buckets[#daily_buckets + 1] = {
|
||||||
|
bucket_id = tonumber(daily_row.bucket_id),
|
||||||
|
low = tonumber(daily_row.low_price) or state.price,
|
||||||
|
high = tonumber(daily_row.high_price) or state.price,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
add_market_daily_price(daily_buckets, state.price, timestamp)
|
||||||
|
next_market_daily_buckets[market_id] = daily_buckets
|
||||||
|
end
|
||||||
|
market_state = next_market_state
|
||||||
|
market_history = next_market_history
|
||||||
|
market_daily_buckets = next_market_daily_buckets
|
||||||
|
end
|
||||||
|
|
||||||
|
local function persist_market_cache()
|
||||||
|
local queries = {}
|
||||||
|
local persisted_markets = {}
|
||||||
|
for _, market_id in ipairs(market_order) do
|
||||||
|
local state = market_state[market_id]
|
||||||
|
if state and state.dirty then
|
||||||
|
queries[#queries + 1] = {
|
||||||
|
query = [[
|
||||||
|
UPDATE `sky_phone_crypto_markets`
|
||||||
|
SET `price` = ?, `version` = ?, `status` = ?, `updated_at` = FROM_UNIXTIME(?)
|
||||||
|
WHERE `id` = ?
|
||||||
|
]],
|
||||||
|
params = { state.price, state.version, state.status, state.updated_at, market_id },
|
||||||
|
}
|
||||||
|
queries[#queries + 1] = {
|
||||||
|
query = [[
|
||||||
|
INSERT INTO `sky_phone_crypto_market_ticks`
|
||||||
|
(`market_id`,`version`,`price`,`created_at`) VALUES (?, ?, ?, FROM_UNIXTIME(?))
|
||||||
|
]],
|
||||||
|
params = { market_id, state.version, state.price, state.updated_at },
|
||||||
|
}
|
||||||
|
persisted_markets[#persisted_markets + 1] = market_id
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if #queries == 0 then
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
queries[#queries + 1] = {
|
||||||
|
query = [[
|
||||||
|
DELETE FROM `sky_phone_crypto_market_ticks`
|
||||||
|
WHERE `created_at` < DATE_SUB(CURRENT_TIMESTAMP, INTERVAL 24 HOUR)
|
||||||
|
]],
|
||||||
|
params = {},
|
||||||
|
}
|
||||||
|
if not Bridge.Database.Transaction(queries) then
|
||||||
|
print("[sky_phone] Failed to persist the crypto market cache.")
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
for _, market_id in ipairs(persisted_markets) do
|
||||||
|
market_state[market_id].dirty = false
|
||||||
|
end
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
local function require_phone(source)
|
local function require_phone(source)
|
||||||
local phone_session, error_response = SkyPhone.RequireSession(source)
|
local phone_session, error_response = SkyPhone.RequireSession(source)
|
||||||
if not phone_session then
|
if not phone_session then
|
||||||
@@ -451,20 +594,7 @@ local function balance(account, asset)
|
|||||||
row and (tonumber(row.version) or 0) or 0
|
row and (tonumber(row.version) or 0) or 0
|
||||||
end
|
end
|
||||||
|
|
||||||
local function market_rows()
|
|
||||||
local rows = Bridge.Database.Query([[
|
|
||||||
SELECT `id`,`price`,`version`,`status`, UNIX_TIMESTAMP(`updated_at`) AS `updated_at`
|
|
||||||
FROM `sky_phone_crypto_markets`
|
|
||||||
]], {})
|
|
||||||
local indexed = {}
|
|
||||||
for _, row in ipairs(rows) do
|
|
||||||
indexed[row.id] = row
|
|
||||||
end
|
|
||||||
return indexed
|
|
||||||
end
|
|
||||||
|
|
||||||
local function market_dtos(selected_market_ids)
|
local function market_dtos(selected_market_ids)
|
||||||
local current = market_rows()
|
|
||||||
local selected = nil
|
local selected = nil
|
||||||
if selected_market_ids then
|
if selected_market_ids then
|
||||||
selected = {}
|
selected = {}
|
||||||
@@ -472,28 +602,17 @@ local function market_dtos(selected_market_ids)
|
|||||||
selected[market_id] = true
|
selected[market_id] = true
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
local daily_rows = Bridge.Database.Query([[
|
|
||||||
SELECT `market_id`, MIN(`price`) AS `low`, MAX(`price`) AS `high`
|
|
||||||
FROM `sky_phone_crypto_market_ticks`
|
|
||||||
WHERE `created_at` >= DATE_SUB(CURRENT_TIMESTAMP, INTERVAL 24 HOUR)
|
|
||||||
GROUP BY `market_id`
|
|
||||||
]], {})
|
|
||||||
local daily = {}
|
|
||||||
for _, daily_row in ipairs(daily_rows) do
|
|
||||||
daily[daily_row.market_id] = daily_row
|
|
||||||
end
|
|
||||||
local result = {}
|
local result = {}
|
||||||
|
local timestamp = os.time()
|
||||||
for _, market_id in ipairs(market_order) do
|
for _, market_id in ipairs(market_order) do
|
||||||
if not selected or selected[market_id] then
|
if not selected or selected[market_id] then
|
||||||
local config = markets[market_id]
|
local config = markets[market_id]
|
||||||
local row = current[market_id]
|
local row = market_state[market_id]
|
||||||
local ticks = Bridge.Database.Query([[
|
local history = market_history[market_id]
|
||||||
SELECT `price` FROM `sky_phone_crypto_market_ticks`
|
|
||||||
WHERE `market_id` = ? ORDER BY `id` DESC LIMIT ?
|
|
||||||
]], { market_id, Config.Crypto.SparklinePoints })
|
|
||||||
local prices = {}
|
local prices = {}
|
||||||
for index = #ticks, 1, -1 do
|
local first_history_index = math.max(1, #history - Config.Crypto.SparklinePoints + 1)
|
||||||
prices[#prices + 1] = tonumber(ticks[index].price) or tonumber(row.price)
|
for index = first_history_index, #history do
|
||||||
|
prices[#prices + 1] = history[index]
|
||||||
end
|
end
|
||||||
if #prices == 0 then
|
if #prices == 0 then
|
||||||
prices[1] = tonumber(row.price)
|
prices[1] = tonumber(row.price)
|
||||||
@@ -509,7 +628,7 @@ local function market_dtos(selected_market_ids)
|
|||||||
end
|
end
|
||||||
local first = prices[1]
|
local first = prices[1]
|
||||||
local price = tonumber(row.price) or config.InitialPrice
|
local price = tonumber(row.price) or config.InitialPrice
|
||||||
local daily_range = daily[market_id]
|
local daily_low, daily_high = market_daily_range(market_id, price, timestamp)
|
||||||
result[#result + 1] = {
|
result[#result + 1] = {
|
||||||
id = market_id,
|
id = market_id,
|
||||||
symbol = config.Symbol,
|
symbol = config.Symbol,
|
||||||
@@ -519,13 +638,13 @@ local function market_dtos(selected_market_ids)
|
|||||||
price = decimal_string(price, Config.Crypto.PriceScale),
|
price = decimal_string(price, Config.Crypto.PriceScale),
|
||||||
changePercent = first > 0 and ((price - first) / first) * 100 or 0,
|
changePercent = first > 0 and ((price - first) / first) * 100 or 0,
|
||||||
enabled = row.status == "active",
|
enabled = row.status == "active",
|
||||||
high24h = decimal_string(daily_range and daily_range.high or maximum, Config.Crypto.PriceScale),
|
high24h = decimal_string(daily_high, Config.Crypto.PriceScale),
|
||||||
low24h = decimal_string(daily_range and daily_range.low or minimum, Config.Crypto.PriceScale),
|
low24h = decimal_string(daily_low, Config.Crypto.PriceScale),
|
||||||
issuedSupply = decimal_string(config.IssuedSupply * Config.Crypto.AssetScale, Config.Crypto.AssetScale),
|
issuedSupply = decimal_string(config.IssuedSupply * Config.Crypto.AssetScale, Config.Crypto.AssetScale),
|
||||||
treasuryAvailable = decimal_string(balance("treasury", market_id), Config.Crypto.AssetScale),
|
treasuryAvailable = decimal_string(balance("treasury", market_id), Config.Crypto.AssetScale),
|
||||||
priceHistory = price_history,
|
priceHistory = price_history,
|
||||||
sparkline = sparkline,
|
sparkline = sparkline,
|
||||||
updatedAt = (tonumber(row.updated_at) or os.time()) * 1000,
|
updatedAt = (tonumber(row.updated_at) or timestamp) * 1000,
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -567,13 +686,12 @@ end
|
|||||||
|
|
||||||
local function bootstrap(profile)
|
local function bootstrap(profile)
|
||||||
local cash = balance(account_id(profile.id), "CASH")
|
local cash = balance(account_id(profile.id), "CASH")
|
||||||
local current_markets = market_rows()
|
|
||||||
local holdings = {}
|
local holdings = {}
|
||||||
local portfolio = cash
|
local portfolio = cash
|
||||||
for _, market_id in ipairs(market_order) do
|
for _, market_id in ipairs(market_order) do
|
||||||
local available = balance(account_id(profile.id), market_id)
|
local available = balance(account_id(profile.id), market_id)
|
||||||
if available > 0 then
|
if available > 0 then
|
||||||
local price = tonumber(current_markets[market_id].price) or 0
|
local price = tonumber(market_state[market_id].price) or 0
|
||||||
local value = math.floor(available * price / Config.Crypto.AssetScale)
|
local value = math.floor(available * price / Config.Crypto.AssetScale)
|
||||||
local fill = Bridge.Database.Query([[
|
local fill = Bridge.Database.Query([[
|
||||||
SELECT FLOOR(SUM(fill.`gross`) * ? / NULLIF(SUM(fill.`quantity`), 0)) AS `price`
|
SELECT FLOOR(SUM(fill.`gross`) * ? / NULLIF(SUM(fill.`quantity`), 0)) AS `price`
|
||||||
@@ -1044,10 +1162,7 @@ Bridge.Callbacks.Register("sky_phone:crypto:quote", function(source, data)
|
|||||||
if not config or not side or not quantity then
|
if not config or not side or not quantity then
|
||||||
return { success = false, error = "invalid_quantity" }
|
return { success = false, error = "invalid_quantity" }
|
||||||
end
|
end
|
||||||
local market = Bridge.Database.Query(
|
local market = market_state[config.Id]
|
||||||
"SELECT `price`,`version`,`status` FROM `sky_phone_crypto_markets` WHERE `id` = ? LIMIT 1",
|
|
||||||
{ config.Id }
|
|
||||||
)[1]
|
|
||||||
if not market or market.status ~= "active" then
|
if not market or market.status ~= "active" then
|
||||||
return { success = false, error = "market_unavailable" }
|
return { success = false, error = "market_unavailable" }
|
||||||
end
|
end
|
||||||
@@ -1127,9 +1242,8 @@ local function execute_trade(profile, data)
|
|||||||
or { success = false, error = "duplicate_request" }
|
or { success = false, error = "duplicate_request" }
|
||||||
end
|
end
|
||||||
local quote = Bridge.Database.Query([[
|
local quote = Bridge.Database.Query([[
|
||||||
SELECT quote.*, market.`status` AS `market_status`, market.`version` AS `current_version`
|
SELECT quote.*
|
||||||
FROM `sky_phone_crypto_quotes` quote
|
FROM `sky_phone_crypto_quotes` quote
|
||||||
JOIN `sky_phone_crypto_markets` market ON market.`id` = quote.`market_id`
|
|
||||||
WHERE quote.`id` = ? AND quote.`profile_id` = ? LIMIT 1
|
WHERE quote.`id` = ? AND quote.`profile_id` = ? LIMIT 1
|
||||||
]], { data.quoteId, profile.id })[1]
|
]], { data.quoteId, profile.id })[1]
|
||||||
if not quote or quote.consumed_operation_id then
|
if not quote or quote.consumed_operation_id then
|
||||||
@@ -1142,7 +1256,10 @@ local function execute_trade(profile, data)
|
|||||||
if not expiry or tonumber(expiry.expires_at) < os.time() then
|
if not expiry or tonumber(expiry.expires_at) < os.time() then
|
||||||
return { success = false, error = "quote_expired" }
|
return { success = false, error = "quote_expired" }
|
||||||
end
|
end
|
||||||
if quote.market_status ~= "active" or tonumber(quote.current_version) ~= tonumber(quote.market_version) then
|
local current_market = market_state[quote.market_id]
|
||||||
|
if not current_market or current_market.status ~= "active"
|
||||||
|
or current_market.version ~= tonumber(quote.market_version)
|
||||||
|
then
|
||||||
return { success = false, error = "quote_expired" }
|
return { success = false, error = "quote_expired" }
|
||||||
end
|
end
|
||||||
local quantity = tonumber(quote.quantity)
|
local quantity = tonumber(quote.quantity)
|
||||||
@@ -1367,6 +1484,7 @@ end)
|
|||||||
ensure_schema()
|
ensure_schema()
|
||||||
migrate_crypto_keys()
|
migrate_crypto_keys()
|
||||||
initialize_markets()
|
initialize_markets()
|
||||||
|
load_market_cache()
|
||||||
|
|
||||||
local function reconcile_settlements(include_recent)
|
local function reconcile_settlements(include_recent)
|
||||||
local age_clause = include_recent and "" or " AND settlement.`updated_at` < DATE_SUB(CURRENT_TIMESTAMP, INTERVAL 5 MINUTE)"
|
local age_clause = include_recent and "" or " AND settlement.`updated_at` < DATE_SUB(CURRENT_TIMESTAMP, INTERVAL 5 MINUTE)"
|
||||||
@@ -1507,6 +1625,16 @@ local function start_crypto_schedulers()
|
|||||||
local generation = scheduler_generation
|
local generation = scheduler_generation
|
||||||
reconcile_settlements(true)
|
reconcile_settlements(true)
|
||||||
|
|
||||||
|
CreateThread(function()
|
||||||
|
while scheduler_generation == generation and Config.Crypto.Enabled == true do
|
||||||
|
Wait(market_persistence_interval)
|
||||||
|
if scheduler_generation ~= generation or Config.Crypto.Enabled ~= true then
|
||||||
|
break
|
||||||
|
end
|
||||||
|
with_exchange_lock(persist_market_cache)
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
|
||||||
CreateThread(function()
|
CreateThread(function()
|
||||||
while scheduler_generation == generation and Config.Crypto.Enabled == true do
|
while scheduler_generation == generation and Config.Crypto.Enabled == true do
|
||||||
Wait(5 * 60 * 1000)
|
Wait(5 * 60 * 1000)
|
||||||
@@ -1536,16 +1664,17 @@ local function start_crypto_schedulers()
|
|||||||
)
|
)
|
||||||
advance_global_market_cycle()
|
advance_global_market_cycle()
|
||||||
local changed_markets = {}
|
local changed_markets = {}
|
||||||
|
local history_limit = math.min(
|
||||||
|
Config.Crypto.HistoryRetentionTicks,
|
||||||
|
Config.Crypto.SparklinePoints
|
||||||
|
)
|
||||||
market_count = math.min(market_count, #market_order)
|
market_count = math.min(market_count, #market_order)
|
||||||
|
|
||||||
for offset = 0, market_count - 1 do
|
for offset = 0, market_count - 1 do
|
||||||
local order_index = ((market_cursor + offset - 1) % #market_order) + 1
|
local order_index = ((market_cursor + offset - 1) % #market_order) + 1
|
||||||
local market_id = market_order[order_index]
|
local market_id = market_order[order_index]
|
||||||
local config = markets[market_id]
|
local config = markets[market_id]
|
||||||
local row = Bridge.Database.Query(
|
local row = market_state[market_id]
|
||||||
"SELECT `price`,`version`,`status` FROM `sky_phone_crypto_markets` WHERE `id` = ? LIMIT 1",
|
|
||||||
{ market_id }
|
|
||||||
)[1]
|
|
||||||
if row and row.status == "active" then
|
if row and row.status == "active" then
|
||||||
local price = tonumber(row.price) or config.InitialPrice
|
local price = tonumber(row.price) or config.InitialPrice
|
||||||
local impulse = crypto_random_int(
|
local impulse = crypto_random_int(
|
||||||
@@ -1614,22 +1743,19 @@ local function start_crypto_schedulers()
|
|||||||
next_price = price + (movement > 0 and 1 or -1)
|
next_price = price + (movement > 0 and 1 or -1)
|
||||||
end
|
end
|
||||||
next_price = math.max(config.MinimumPrice, math.min(config.MaximumPrice, next_price))
|
next_price = math.max(config.MinimumPrice, math.min(config.MaximumPrice, next_price))
|
||||||
local next_version = (tonumber(row.version) or 0) + 1
|
local next_version = row.version + 1
|
||||||
if Bridge.Database.Transaction({
|
local updated_at = os.time()
|
||||||
{ query = [[UPDATE `sky_phone_crypto_markets` SET `price` = ?, `version` = ? WHERE `id` = ? AND `version` = ?]], params = { next_price, next_version, market_id, row.version } },
|
row.price = next_price
|
||||||
{ query = [[INSERT INTO `sky_phone_crypto_market_ticks` (`market_id`,`version`,`price`) VALUES (?, ?, ?)]], params = { market_id, next_version, next_price } },
|
row.version = next_version
|
||||||
}) then
|
row.updated_at = updated_at
|
||||||
changed_markets[#changed_markets + 1] = market_id
|
row.dirty = true
|
||||||
Bridge.Database.Query([[
|
local history = market_history[market_id]
|
||||||
DELETE FROM `sky_phone_crypto_market_ticks`
|
history[#history + 1] = next_price
|
||||||
WHERE `market_id` = ? AND `id` NOT IN (
|
if #history > history_limit then
|
||||||
SELECT `id` FROM (
|
table.remove(history, 1)
|
||||||
SELECT `id` FROM `sky_phone_crypto_market_ticks`
|
|
||||||
WHERE `market_id` = ? ORDER BY `id` DESC LIMIT ?
|
|
||||||
) retained
|
|
||||||
)
|
|
||||||
]], { market_id, market_id, Config.Crypto.HistoryRetentionTicks })
|
|
||||||
end
|
end
|
||||||
|
add_market_daily_price(market_daily_buckets[market_id], next_price, updated_at)
|
||||||
|
changed_markets[#changed_markets + 1] = market_id
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
market_cursor = ((market_cursor + market_count - 1) % #market_order) + 1
|
market_cursor = ((market_cursor + market_count - 1) % #market_order) + 1
|
||||||
@@ -1645,11 +1771,26 @@ local function start_crypto_schedulers()
|
|||||||
end
|
end
|
||||||
|
|
||||||
local function refresh_crypto_runtime()
|
local function refresh_crypto_runtime()
|
||||||
|
if exchange_lock then
|
||||||
|
print("[sky_phone] Crypto runtime refresh skipped because the exchange is busy.")
|
||||||
|
return
|
||||||
|
end
|
||||||
|
with_exchange_lock(function()
|
||||||
|
if not persist_market_cache() then
|
||||||
|
return
|
||||||
|
end
|
||||||
initialize_markets()
|
initialize_markets()
|
||||||
|
load_market_cache()
|
||||||
start_crypto_schedulers()
|
start_crypto_schedulers()
|
||||||
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
AddEventHandler("sky_phone:configurator:serverUpdated", refresh_crypto_runtime)
|
AddEventHandler("sky_phone:configurator:serverUpdated", refresh_crypto_runtime)
|
||||||
|
AddEventHandler("onResourceStop", function(resource_name)
|
||||||
|
if resource_name == GetCurrentResourceName() then
|
||||||
|
persist_market_cache()
|
||||||
|
end
|
||||||
|
end)
|
||||||
start_crypto_schedulers()
|
start_crypto_schedulers()
|
||||||
|
|
||||||
end)
|
end)
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -456,6 +456,29 @@ local schema = {
|
|||||||
},
|
},
|
||||||
tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
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",
|
name = "sky_phone_notes",
|
||||||
columns = {
|
columns = {
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ local is_sequence
|
|||||||
local FIXED_CONFIG_PATHS = {
|
local FIXED_CONFIG_PATHS = {
|
||||||
CommandPermissions = true,
|
CommandPermissions = true,
|
||||||
["AdminPanel.AdminGroups"] = true,
|
["AdminPanel.AdminGroups"] = true,
|
||||||
|
CustomTones = true,
|
||||||
["TestData.AdminGroups"] = true,
|
["TestData.AdminGroups"] = true,
|
||||||
["FlipTok.AdminGroups"] = true,
|
["FlipTok.AdminGroups"] = true,
|
||||||
["Picstagram.AdminGroups"] = true,
|
["Picstagram.AdminGroups"] = true,
|
||||||
@@ -34,7 +35,7 @@ if configurator_enabled then
|
|||||||
^1 The Phone Configurator is ENABLED.^0
|
^1 The Phone Configurator is ENABLED.^0
|
||||||
^1 Runtime settings from config.lua and media.lua are DISABLED.^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 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))
|
^1%s^0]]):format(border, border, border))
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -274,12 +275,22 @@ local function upgrade_legacy_map(defaults, saved)
|
|||||||
return { __skyType = "map", entries = entries }
|
return { __skyType = "map", entries = entries }
|
||||||
end
|
end
|
||||||
|
|
||||||
|
local function radio_job_entry_default(path)
|
||||||
|
if path == "Radio.DisplayName.AllowedJobs" then
|
||||||
|
return 0
|
||||||
|
end
|
||||||
|
if type(path) == "string" and path:match("^Radio%.LockedChannels%.%d+%.jobs$") then
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
local function merge_values(defaults, saved, path, excluded_paths)
|
local function merge_values(defaults, saved, path, excluded_paths)
|
||||||
path = path or ""
|
path = path or ""
|
||||||
if type(defaults) ~= "table" or type(saved) ~= "table" then
|
if type(defaults) ~= "table" or type(saved) ~= "table" then
|
||||||
return copy_value(saved)
|
return copy_value(saved)
|
||||||
end
|
end
|
||||||
if path == "Companies.Definitions" then
|
if path == "Companies.Definitions" or radio_job_entry_default(path) ~= nil then
|
||||||
return copy_value(saved)
|
return copy_value(saved)
|
||||||
end
|
end
|
||||||
if defaults.__skyType == "map" and not saved.__skyType then
|
if defaults.__skyType == "map" and not saved.__skyType then
|
||||||
@@ -385,7 +396,7 @@ local function apply_runtime_configuration()
|
|||||||
|
|
||||||
local runtime_config = deserialize_value(stored_config)
|
local runtime_config = deserialize_value(stored_config)
|
||||||
for key, value in pairs(runtime_config) do
|
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
|
if type(Config[key]) == "table" and type(value) == "table" then
|
||||||
apply_runtime_table(Config[key], value)
|
apply_runtime_table(Config[key], value)
|
||||||
else
|
else
|
||||||
@@ -492,6 +503,16 @@ local function empty_structure(scope, path)
|
|||||||
if scope ~= "config" then
|
if scope ~= "config" then
|
||||||
return nil
|
return nil
|
||||||
end
|
end
|
||||||
|
local radio_job_default = radio_job_entry_default(path)
|
||||||
|
if radio_job_default ~= nil then
|
||||||
|
return {
|
||||||
|
entryDefault = radio_job_default,
|
||||||
|
fields = {},
|
||||||
|
kind = "table",
|
||||||
|
mutableKeys = true,
|
||||||
|
template = { kind = "value", valueType = type(radio_job_default) },
|
||||||
|
}
|
||||||
|
end
|
||||||
if path == "Garage.VehicleImages.ModelNames" then
|
if path == "Garage.VehicleImages.ModelNames" then
|
||||||
return {
|
return {
|
||||||
entries = {},
|
entries = {},
|
||||||
@@ -637,6 +658,21 @@ local function build_structure(value, scope, path)
|
|||||||
if scope == "config" and path == "Phone.Keybind" then
|
if scope == "config" and path == "Phone.Keybind" then
|
||||||
return { kind = "optionalString" }
|
return { kind = "optionalString" }
|
||||||
end
|
end
|
||||||
|
if scope == "config"
|
||||||
|
and path:match("^Radio%.LockedChannels%.%d+%.jobs$")
|
||||||
|
and value_type == "table"
|
||||||
|
then
|
||||||
|
local fields = {}
|
||||||
|
for key, child in pairs(value) do
|
||||||
|
fields[key] = build_structure(child, scope, path .. "." .. tostring(key))
|
||||||
|
end
|
||||||
|
return {
|
||||||
|
fields = fields,
|
||||||
|
kind = "table",
|
||||||
|
mutableKeys = true,
|
||||||
|
template = { kind = "value", valueType = "boolean" },
|
||||||
|
}
|
||||||
|
end
|
||||||
if scope == "config" and path == "Companies.Definitions" and value_type == "table" then
|
if scope == "config" and path == "Companies.Definitions" and value_type == "table" then
|
||||||
local keys = {}
|
local keys = {}
|
||||||
for key in pairs(value) do
|
for key in pairs(value) do
|
||||||
@@ -712,6 +748,16 @@ local function build_structure(value, scope, path)
|
|||||||
for key, child in pairs(value) do
|
for key, child in pairs(value) do
|
||||||
fields[key] = build_structure(child, scope, path .. "." .. tostring(key))
|
fields[key] = build_structure(child, scope, path .. "." .. tostring(key))
|
||||||
end
|
end
|
||||||
|
local radio_job_default = scope == "config" and radio_job_entry_default(path) or nil
|
||||||
|
if radio_job_default ~= nil then
|
||||||
|
return {
|
||||||
|
entryDefault = radio_job_default,
|
||||||
|
fields = fields,
|
||||||
|
kind = "table",
|
||||||
|
mutableKeys = true,
|
||||||
|
template = { kind = "value", valueType = type(radio_job_default) },
|
||||||
|
}
|
||||||
|
end
|
||||||
return {
|
return {
|
||||||
fields = fields,
|
fields = fields,
|
||||||
kind = "table",
|
kind = "table",
|
||||||
@@ -1352,7 +1398,11 @@ end
|
|||||||
|
|
||||||
default_config = {}
|
default_config = {}
|
||||||
for key, value in pairs(ConfigDefaults) do
|
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)
|
default_config[key] = serialize_value(value)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -50,8 +50,14 @@ local function can_set_display_name(source)
|
|||||||
if type(config) ~= "table" or not config.Enabled then
|
if type(config) ~= "table" or not config.Enabled then
|
||||||
return false
|
return false
|
||||||
end
|
end
|
||||||
|
if config.AllowEveryone == true then
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
local job = Bridge.Framework.GetJob(source)
|
local job = Bridge.Framework.GetJob(source)
|
||||||
|
if type(job) ~= "table" or type(job.name) ~= "string" then
|
||||||
|
return false
|
||||||
|
end
|
||||||
local minimum_grade = type(config.AllowedJobs) == "table" and tonumber(config.AllowedJobs[job.name]) or nil
|
local minimum_grade = type(config.AllowedJobs) == "table" and tonumber(config.AllowedJobs[job.name]) or nil
|
||||||
return minimum_grade ~= nil and (tonumber(job.grade) or 0) >= minimum_grade
|
return minimum_grade ~= nil and (tonumber(job.grade) or 0) >= minimum_grade
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1836,18 +1836,23 @@ CreateThread(function()
|
|||||||
while true do
|
while true do
|
||||||
local recovery_success, recovery_rows = pcall(
|
local recovery_success, recovery_rows = pcall(
|
||||||
Bridge.Database.Query,
|
Bridge.Database.Query,
|
||||||
ride_select .. (([[
|
(([[
|
||||||
WHERE r.`refund_status` IN ('pending','completed')
|
SELECT r.`id`, r.`status`, r.`refund_status`, r.`price`,
|
||||||
AND (
|
passenger.`owner_identifier` AS `passenger_identifier`
|
||||||
r.`status` = 'cancelled'
|
FROM `sky_phone_skyride_rides` r
|
||||||
OR (
|
INNER JOIN `sky_phone_skyride_profiles` passenger
|
||||||
r.`status` = 'payment_pending'
|
ON passenger.`id` = r.`passenger_profile_id`
|
||||||
|
WHERE (
|
||||||
|
r.`refund_status` = 'pending'
|
||||||
|
AND r.`status` = 'cancelled'
|
||||||
|
) OR (
|
||||||
|
r.`refund_status` IN ('pending','completed')
|
||||||
|
AND r.`status` = 'payment_pending'
|
||||||
AND r.`updated_at` <= DATE_SUB(
|
AND r.`updated_at` <= DATE_SUB(
|
||||||
CURRENT_TIMESTAMP,
|
CURRENT_TIMESTAMP,
|
||||||
INTERVAL %d SECOND
|
INTERVAL %d SECOND
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
)
|
|
||||||
ORDER BY r.`updated_at` ASC
|
ORDER BY r.`updated_at` ASC
|
||||||
LIMIT 50
|
LIMIT 50
|
||||||
]]):format(math.floor(Config.SkyRide.PaymentPendingRecoverySeconds)))
|
]]):format(math.floor(Config.SkyRide.PaymentPendingRecoverySeconds)))
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ Config.Phone = {
|
|||||||
DeviceName = "iFruit Phone",
|
DeviceName = "iFruit Phone",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
-- Server-wide availability for bundled apps. Set an entry to false to hide it
|
-- 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.
|
-- from every phone, the App Store and per-device app management.
|
||||||
Config.Apps = {
|
Config.Apps = {
|
||||||
@@ -201,8 +202,9 @@ Config.Radio = {
|
|||||||
AutoRejoin = false,
|
AutoRejoin = false,
|
||||||
DisplayName = {
|
DisplayName = {
|
||||||
Enabled = true,
|
Enabled = true,
|
||||||
|
AllowEveryone = false, -- true allows every job; false uses AllowedJobs and its minimum grades
|
||||||
MaxLength = 32,
|
MaxLength = 32,
|
||||||
AllowedJobs = { -- Job name = minimum grade. Unlisted jobs cannot set a radio display name.
|
AllowedJobs = { -- Job name = minimum grade. Used when AllowEveryone is false.
|
||||||
police = 0,
|
police = 0,
|
||||||
sheriff = 0,
|
sheriff = 0,
|
||||||
fib = 0,
|
fib = 0,
|
||||||
@@ -435,8 +437,8 @@ Config.Garage = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Config.Housing = {
|
Config.Housing = {
|
||||||
System = "auto", -- auto, rtx, quasar, vms, rx, nolag, sn, esx_property, qbx_properties
|
System = "auto", -- auto, rtx, quasar, tgiann, vms, rx, nolag, sn, esx_property, qbx_properties
|
||||||
AutoPriority = { "esx_property", "qbx_properties", "rtx", "quasar", "vms", "rx", "nolag", "sn" },
|
AutoPriority = { "esx_property", "qbx_properties", "rtx", "quasar", "tgiann", "vms", "rx", "nolag", "sn" },
|
||||||
MaximumProperties = 50,
|
MaximumProperties = 50,
|
||||||
OverviewRequestsPerMinute = 30,
|
OverviewRequestsPerMinute = 30,
|
||||||
ActionsPerMinute = 12,
|
ActionsPerMinute = 12,
|
||||||
|
|||||||
@@ -200,6 +200,22 @@ CREATE TABLE IF NOT EXISTS `sky_phone_admin_audit` (
|
|||||||
KEY `idx_sky_phone_admin_audit_target` (`target_identifier`, `created_at`)
|
KEY `idx_sky_phone_admin_audit_target` (`target_identifier`, `created_at`)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
) 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` (
|
CREATE TABLE IF NOT EXISTS `sky_phone_configurator` (
|
||||||
`id` TINYINT UNSIGNED NOT NULL,
|
`id` TINYINT UNSIGNED NOT NULL,
|
||||||
`config_payload` LONGTEXT NOT NULL,
|
`config_payload` LONGTEXT NOT NULL,
|
||||||
|
|||||||
@@ -0,0 +1,145 @@
|
|||||||
|
-- sky_phone destructive uninstall script
|
||||||
|
--
|
||||||
|
-- WARNING: Running this file permanently deletes all sky_phone data from the
|
||||||
|
-- currently selected database. Stop the sky_phone resource and create a backup
|
||||||
|
-- before executing it. This script does not remove tables owned by other resources.
|
||||||
|
|
||||||
|
SET @sky_phone_previous_foreign_key_checks = @@FOREIGN_KEY_CHECKS;
|
||||||
|
SET FOREIGN_KEY_CHECKS = 0;
|
||||||
|
|
||||||
|
DROP TABLE IF EXISTS `sky_phone_accounts`;
|
||||||
|
DROP TABLE IF EXISTS `sky_phone_admin_audit`;
|
||||||
|
DROP TABLE IF EXISTS `sky_phone_bank_transactions`;
|
||||||
|
DROP TABLE IF EXISTS `sky_phone_billing_accounts`;
|
||||||
|
DROP TABLE IF EXISTS `sky_phone_billing_events`;
|
||||||
|
DROP TABLE IF EXISTS `sky_phone_billing_invoices`;
|
||||||
|
DROP TABLE IF EXISTS `sky_phone_billing_payments`;
|
||||||
|
DROP TABLE IF EXISTS `sky_phone_calendar_events`;
|
||||||
|
DROP TABLE IF EXISTS `sky_phone_call_blocks`;
|
||||||
|
DROP TABLE IF EXISTS `sky_phone_call_entries`;
|
||||||
|
DROP TABLE IF EXISTS `sky_phone_calls`;
|
||||||
|
DROP TABLE IF EXISTS `sky_phone_character_devices`;
|
||||||
|
DROP TABLE IF EXISTS `sky_phone_citywarn_alerts`;
|
||||||
|
DROP TABLE IF EXISTS `sky_phone_citywarn_updates`;
|
||||||
|
DROP TABLE IF EXISTS `sky_phone_company_announcements`;
|
||||||
|
DROP TABLE IF EXISTS `sky_phone_company_audit`;
|
||||||
|
DROP TABLE IF EXISTS `sky_phone_company_hours`;
|
||||||
|
DROP TABLE IF EXISTS `sky_phone_company_profiles`;
|
||||||
|
DROP TABLE IF EXISTS `sky_phone_company_request_events`;
|
||||||
|
DROP TABLE IF EXISTS `sky_phone_company_request_media`;
|
||||||
|
DROP TABLE IF EXISTS `sky_phone_company_request_messages`;
|
||||||
|
DROP TABLE IF EXISTS `sky_phone_company_request_reads`;
|
||||||
|
DROP TABLE IF EXISTS `sky_phone_company_requests`;
|
||||||
|
DROP TABLE IF EXISTS `sky_phone_company_services`;
|
||||||
|
DROP TABLE IF EXISTS `sky_phone_configurator`;
|
||||||
|
DROP TABLE IF EXISTS `sky_phone_contacts`;
|
||||||
|
DROP TABLE IF EXISTS `sky_phone_crewlink_credentials`;
|
||||||
|
DROP TABLE IF EXISTS `sky_phone_crewlink_groups`;
|
||||||
|
DROP TABLE IF EXISTS `sky_phone_crewlink_invitations`;
|
||||||
|
DROP TABLE IF EXISTS `sky_phone_crewlink_memberships`;
|
||||||
|
DROP TABLE IF EXISTS `sky_phone_crewlink_pings`;
|
||||||
|
DROP TABLE IF EXISTS `sky_phone_crewlink_profiles`;
|
||||||
|
DROP TABLE IF EXISTS `sky_phone_crewlink_sessions`;
|
||||||
|
DROP TABLE IF EXISTS `sky_phone_crypto_audit_events`;
|
||||||
|
DROP TABLE IF EXISTS `sky_phone_crypto_balances`;
|
||||||
|
DROP TABLE IF EXISTS `sky_phone_crypto_fills`;
|
||||||
|
DROP TABLE IF EXISTS `sky_phone_crypto_ledger_entries`;
|
||||||
|
DROP TABLE IF EXISTS `sky_phone_crypto_market_ticks`;
|
||||||
|
DROP TABLE IF EXISTS `sky_phone_crypto_markets`;
|
||||||
|
DROP TABLE IF EXISTS `sky_phone_crypto_operations`;
|
||||||
|
DROP TABLE IF EXISTS `sky_phone_crypto_profiles`;
|
||||||
|
DROP TABLE IF EXISTS `sky_phone_crypto_quotes`;
|
||||||
|
DROP TABLE IF EXISTS `sky_phone_crypto_settlements`;
|
||||||
|
DROP TABLE IF EXISTS `sky_phone_custom_app_data`;
|
||||||
|
DROP TABLE IF EXISTS `sky_phone_custom_tones`;
|
||||||
|
DROP TABLE IF EXISTS `sky_phone_darkchat_blocks`;
|
||||||
|
DROP TABLE IF EXISTS `sky_phone_darkchat_contacts`;
|
||||||
|
DROP TABLE IF EXISTS `sky_phone_darkchat_conversations`;
|
||||||
|
DROP TABLE IF EXISTS `sky_phone_darkchat_members`;
|
||||||
|
DROP TABLE IF EXISTS `sky_phone_darkchat_messages`;
|
||||||
|
DROP TABLE IF EXISTS `sky_phone_darkchat_profiles`;
|
||||||
|
DROP TABLE IF EXISTS `sky_phone_darkchat_reports`;
|
||||||
|
DROP TABLE IF EXISTS `sky_phone_device_data`;
|
||||||
|
DROP TABLE IF EXISTS `sky_phone_device_security`;
|
||||||
|
DROP TABLE IF EXISTS `sky_phone_devices`;
|
||||||
|
DROP TABLE IF EXISTS `sky_phone_easyshare_preferences`;
|
||||||
|
DROP TABLE IF EXISTS `sky_phone_easyshare_transfers`;
|
||||||
|
DROP TABLE IF EXISTS `sky_phone_feather_blocks`;
|
||||||
|
DROP TABLE IF EXISTS `sky_phone_feather_follows`;
|
||||||
|
DROP TABLE IF EXISTS `sky_phone_feather_hashtags`;
|
||||||
|
DROP TABLE IF EXISTS `sky_phone_feather_notifications`;
|
||||||
|
DROP TABLE IF EXISTS `sky_phone_feather_post_media`;
|
||||||
|
DROP TABLE IF EXISTS `sky_phone_feather_posts`;
|
||||||
|
DROP TABLE IF EXISTS `sky_phone_feather_profiles`;
|
||||||
|
DROP TABLE IF EXISTS `sky_phone_feather_reactions`;
|
||||||
|
DROP TABLE IF EXISTS `sky_phone_feather_reports`;
|
||||||
|
DROP TABLE IF EXISTS `sky_phone_flare_matches`;
|
||||||
|
DROP TABLE IF EXISTS `sky_phone_flare_messages`;
|
||||||
|
DROP TABLE IF EXISTS `sky_phone_flare_profile_photos`;
|
||||||
|
DROP TABLE IF EXISTS `sky_phone_flare_profiles`;
|
||||||
|
DROP TABLE IF EXISTS `sky_phone_flare_swipes`;
|
||||||
|
DROP TABLE IF EXISTS `sky_phone_fliptok_blocks`;
|
||||||
|
DROP TABLE IF EXISTS `sky_phone_fliptok_comment_reactions`;
|
||||||
|
DROP TABLE IF EXISTS `sky_phone_fliptok_comments`;
|
||||||
|
DROP TABLE IF EXISTS `sky_phone_fliptok_credentials`;
|
||||||
|
DROP TABLE IF EXISTS `sky_phone_fliptok_follows`;
|
||||||
|
DROP TABLE IF EXISTS `sky_phone_fliptok_notifications`;
|
||||||
|
DROP TABLE IF EXISTS `sky_phone_fliptok_profiles`;
|
||||||
|
DROP TABLE IF EXISTS `sky_phone_fliptok_reactions`;
|
||||||
|
DROP TABLE IF EXISTS `sky_phone_fliptok_reports`;
|
||||||
|
DROP TABLE IF EXISTS `sky_phone_fliptok_sessions`;
|
||||||
|
DROP TABLE IF EXISTS `sky_phone_fliptok_video_media`;
|
||||||
|
DROP TABLE IF EXISTS `sky_phone_fliptok_videos`;
|
||||||
|
DROP TABLE IF EXISTS `sky_phone_health_daily`;
|
||||||
|
DROP TABLE IF EXISTS `sky_phone_health_profiles`;
|
||||||
|
DROP TABLE IF EXISTS `sky_phone_mail_drafts`;
|
||||||
|
DROP TABLE IF EXISTS `sky_phone_mail_entries`;
|
||||||
|
DROP TABLE IF EXISTS `sky_phone_mail_messages`;
|
||||||
|
DROP TABLE IF EXISTS `sky_phone_mailboxes`;
|
||||||
|
DROP TABLE IF EXISTS `sky_phone_map_markers`;
|
||||||
|
DROP TABLE IF EXISTS `sky_phone_marketplace_blocks`;
|
||||||
|
DROP TABLE IF EXISTS `sky_phone_marketplace_favorites`;
|
||||||
|
DROP TABLE IF EXISTS `sky_phone_marketplace_images`;
|
||||||
|
DROP TABLE IF EXISTS `sky_phone_marketplace_inquiries`;
|
||||||
|
DROP TABLE IF EXISTS `sky_phone_marketplace_listings`;
|
||||||
|
DROP TABLE IF EXISTS `sky_phone_marketplace_messages`;
|
||||||
|
DROP TABLE IF EXISTS `sky_phone_marketplace_offers`;
|
||||||
|
DROP TABLE IF EXISTS `sky_phone_marketplace_profiles`;
|
||||||
|
DROP TABLE IF EXISTS `sky_phone_marketplace_reports`;
|
||||||
|
DROP TABLE IF EXISTS `sky_phone_media`;
|
||||||
|
DROP TABLE IF EXISTS `sky_phone_migration_numbers`;
|
||||||
|
DROP TABLE IF EXISTS `sky_phone_migration_owners`;
|
||||||
|
DROP TABLE IF EXISTS `sky_phone_migrations`;
|
||||||
|
DROP TABLE IF EXISTS `sky_phone_music_playlist_items`;
|
||||||
|
DROP TABLE IF EXISTS `sky_phone_music_playlists`;
|
||||||
|
DROP TABLE IF EXISTS `sky_phone_music_youtube_songs`;
|
||||||
|
DROP TABLE IF EXISTS `sky_phone_notes`;
|
||||||
|
DROP TABLE IF EXISTS `sky_phone_pages_images`;
|
||||||
|
DROP TABLE IF EXISTS `sky_phone_pages_posts`;
|
||||||
|
DROP TABLE IF EXISTS `sky_phone_pages_profiles`;
|
||||||
|
DROP TABLE IF EXISTS `sky_phone_pages_reactions`;
|
||||||
|
DROP TABLE IF EXISTS `sky_phone_picstagram_activities`;
|
||||||
|
DROP TABLE IF EXISTS `sky_phone_picstagram_blocks`;
|
||||||
|
DROP TABLE IF EXISTS `sky_phone_picstagram_comment_reactions`;
|
||||||
|
DROP TABLE IF EXISTS `sky_phone_picstagram_comments`;
|
||||||
|
DROP TABLE IF EXISTS `sky_phone_picstagram_credentials`;
|
||||||
|
DROP TABLE IF EXISTS `sky_phone_picstagram_follows`;
|
||||||
|
DROP TABLE IF EXISTS `sky_phone_picstagram_moderation_audit`;
|
||||||
|
DROP TABLE IF EXISTS `sky_phone_picstagram_post_media`;
|
||||||
|
DROP TABLE IF EXISTS `sky_phone_picstagram_posts`;
|
||||||
|
DROP TABLE IF EXISTS `sky_phone_picstagram_profiles`;
|
||||||
|
DROP TABLE IF EXISTS `sky_phone_picstagram_reactions`;
|
||||||
|
DROP TABLE IF EXISTS `sky_phone_picstagram_reports`;
|
||||||
|
DROP TABLE IF EXISTS `sky_phone_picstagram_sessions`;
|
||||||
|
DROP TABLE IF EXISTS `sky_phone_picstagram_stories`;
|
||||||
|
DROP TABLE IF EXISTS `sky_phone_picstagram_story_views`;
|
||||||
|
DROP TABLE IF EXISTS `sky_phone_radio_profiles`;
|
||||||
|
DROP TABLE IF EXISTS `sky_phone_sims`;
|
||||||
|
DROP TABLE IF EXISTS `sky_phone_skyride_profiles`;
|
||||||
|
DROP TABLE IF EXISTS `sky_phone_skyride_rides`;
|
||||||
|
DROP TABLE IF EXISTS `sky_phone_sms_messages`;
|
||||||
|
DROP TABLE IF EXISTS `sky_phone_voice_memos`;
|
||||||
|
DROP TABLE IF EXISTS `sky_phone_weazel_article_media`;
|
||||||
|
DROP TABLE IF EXISTS `sky_phone_weazel_articles`;
|
||||||
|
|
||||||
|
SET FOREIGN_KEY_CHECKS = @sky_phone_previous_foreign_key_checks;
|
||||||
@@ -252,7 +252,7 @@ assert(firing_disabled, "focused phone cursor must block attacks while typing")
|
|||||||
all_controls_disabled = {}
|
all_controls_disabled = {}
|
||||||
firing_disabled = false
|
firing_disabled = false
|
||||||
SkyPhoneFocus.ApplyGameInputControls(true)
|
SkyPhoneFocus.ApplyGameInputControls(true)
|
||||||
for _, control in ipairs({ 24, 140, 141, 142, 257, 263, 264 }) do
|
for _, control in ipairs({ 24, 140, 141, 142, 199, 200, 257, 263, 264 }) do
|
||||||
assert(disabled_controls[control], ("phone control %d must remain disabled"):format(control))
|
assert(disabled_controls[control], ("phone control %d must remain disabled"):format(control))
|
||||||
end
|
end
|
||||||
assert(not disabled_controls[19], "Alt must remain available while no phone text input is focused")
|
assert(not disabled_controls[19], "Alt must remain available while no phone text input is focused")
|
||||||
|
|||||||
Reference in New Issue
Block a user