Merge branch 'feature/funk' into dev

This commit is contained in:
Alec Schitzkat
2026-08-09 05:30:27 +02:00
57 changed files with 2721 additions and 66 deletions
+9
View File
@@ -52,6 +52,7 @@ An iFruit account is optional. Unlinked devices retain local settings, alarms, m
- A FiveManage V3 Media API token for Camera photo/video uploads and Gallery deletion. Set the
server-only `Config.Media.FiveManage.ApiKey` in `sky_phone/config/media.lua`; the token is never
sent to NUI because clients receive temporary presigned upload URLs instead.
- `yaca-voice`, `pma-voice`, or `saltychat` when the Radio app is enabled. `Config.Radio.VoiceProvider = "auto"` selects the first running provider in that order.
## Messages GIF provider
@@ -77,6 +78,14 @@ For a fresh manual database installation, import `sky_phone/sql/install.sql`. It
Framework, inventory, callback, notification, and database integrations live under `sky_phone/source/bridge`. The resource has no dependency on any other Sky resource.
## Radio app
The built-in Radio app supports a primary frequency, volume, recent channels, participant lists, automatic rejoin, join/leave notifications, and an optional service number. YACA and SaltyChat support the configured secondary frequency; PMA Voice exposes one radio channel, so the secondary input is hidden automatically.
Configure frequency bounds and precision, restricted channel ranges and allowed jobs, history length, defaults, badge validation, radio display-name permissions, and the built-in speaker HUD under `Config.Radio`. `Config.Radio.DisplayName.AllowedJobs` maps authoritative framework job names to their minimum grade. Unlisted jobs cannot change the name; an empty name restores the normal player or character name. Channel and display-name access are always checked server-side. `Config.Radio.Hud` controls the phone-owned overlay, its screen edge, offsets, and recent-speaker duration without depending on another HUD resource. Active-speaker highlighting uses the YACA radio events; the Radio app itself continues to support every configured voice provider.
Radio profiles are stored in `sky_phone_radio_profiles`. Runtime migration creates the table automatically; fresh installations receive it through `sky_phone/sql/install.sql`.
Inventory metadata has no framework-wide standard: providers differ in export names, callback payloads, slot handling, and whether metadata is called `metadata` or `info`. For that reason, `sky_phone` uses explicit provider adapters instead of guessing exports at runtime. Every supported adapter implements slot lookup, item lookup, metadata replacement, capacity handling, add/remove operations, and usable-item registration. Providers without a separate capacity export use their authoritative add operation as the final capacity gate.
When a SIM is ejected or replaced, the returned inventory item is rebuilt from the authoritative `sky_phone_sims` row. Its metadata contains `sim_metadata_version`, `sim_id`, `phone_number`, `formatted_number`, and `sim_type`. Registered SIMs additionally contain `firstname`, `lastname`, `birthdate`, and `registered_at`. The internal framework owner identifier remains database-only. Inserting the item again resolves the SIM by `sim_id`; contacts and device/cloud data remain attached to their existing phone-owned persistence instead of being copied into inventory metadata.
+17 -2
View File
@@ -18,6 +18,7 @@ import PhonePasscode from '@/components/PhonePasscode.vue'
import PhoneNotifications from '@/components/PhoneNotifications.vue'
import NotificationPhonePreview from '@/components/NotificationPhonePreview.vue'
import PhoneStatusBar from '@/components/PhoneStatusBar.vue'
import RadioHud from '@/components/RadioHud.vue'
import SimPhonePicker, {
type SimPhoneChoice,
} from '@/components/SimPhonePicker.vue'
@@ -352,6 +353,7 @@ function onMessage(event: MessageEvent<AppMessage>): void {
appId: 'calendar',
subtitle: new Intl.DateTimeFormat(phone.lang, {
hour: '2-digit',
hourCycle: 'h23',
minute: '2-digit',
}).format(startsAt),
text:
@@ -707,6 +709,7 @@ onBeforeUnmount(() => {
<template>
<PhoneMediaCapture />
<RadioHud />
<SimPhonePicker
v-if="simPicker"
:choices="simPicker.choices"
@@ -745,10 +748,21 @@ onBeforeUnmount(() => {
v-if="phone.isOpen || notifications.current"
class="phone-resolution-wrapper phone-resolution-wrapper--primary"
>
<section class="phone-device" :aria-label="phone.t('Common.phone')">
<section
class="phone-device"
:class="{
'phone-app--light': !phone.isDarkMode,
[`phone-app--${phone.preferences.settings.graphicsMode}`]: true,
}"
:aria-label="phone.t('Common.phone')"
>
<div
class="phone-screen"
:class="{ 'phone-screen--app': isAppRoute }"
:class="{
'phone-screen--app': isAppRoute,
'phone-app--light': !phone.isDarkMode,
[`phone-app--${phone.preferences.settings.graphicsMode}`]: true,
}"
>
<k-app
theme="ios"
@@ -759,6 +773,7 @@ onBeforeUnmount(() => {
:class="{
dark: phone.isDarkMode,
'phone-app--light': !phone.isDarkMode,
[`phone-app--${phone.preferences.settings.graphicsMode}`]: true,
'phone-app--unlocking': isUnlocking,
}"
>
@@ -0,0 +1,15 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 120">
<defs>
<linearGradient id="radio-bg" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#0a84ff"/>
<stop offset="1" stop-color="#0056b3"/>
</linearGradient>
</defs>
<rect width="120" height="120" rx="26" fill="url(#radio-bg)"/>
<g transform="translate(60 60)" fill="none" stroke="#fff" stroke-width="4" stroke-linecap="round">
<path d="M0-10V-38"/>
<circle cx="0" cy="-40" r="3" fill="#fff" stroke="none"/>
<rect x="-22" y="-10" width="44" height="48" rx="6"/>
<path d="M-10 4H10M-10 12H10M-10 20H10M18-26c10 6 10 20 0 26M24-30c14 10 14 28 0 38"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 676 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

+98 -14
View File
@@ -324,6 +324,81 @@ button {
.phone-app--light {
color-scheme: light;
}
/* FiveM CEF blur-free glass fallback. Ultimate keeps the native effects. */
.phone-app--performance {
--phone-performance-glass: rgb(28 28 31 / 96%);
}
.phone-app--performance.phone-app--light {
--phone-performance-glass: rgb(245 245 248 / 97%);
}
.phone-device.phone-app--performance {
filter: none;
}
.phone-app--performance,
.phone-app--performance *,
.phone-app--performance *::before,
.phone-app--performance *::after {
backdrop-filter: none !important;
-webkit-backdrop-filter: none !important;
}
.phone-app--performance .k-glass {
background-color: var(--phone-performance-glass) !important;
}
.phone-app--performance .control-center__backdrop {
background: rgb(20 20 24 / 92%);
}
.phone-app--performance .app-dock {
background-color: rgb(38 38 46 / 92%);
}
.phone-app--performance .widget {
background-color: rgb(21 24 34 / 95%);
}
.phone-app--performance .app-library-search,
.phone-app--performance .app-search,
.phone-app--performance .app-library-group__icons {
background-color: rgb(18 18 25 / 92%);
}
.phone-app--performance .app-library-all {
background-color: rgb(9 9 16 / 97%);
}
.phone-screen--app .phone-app--performance .springboard,
.phone-app--performance .lock-screen__time,
.phone-app--performance .lock-screen-leave-to,
.phone-app--performance .minesweeper-marker-drop__trail,
.phone-app--performance .flappy-clouds i::after {
filter: none !important;
}
.phone-app--performance .springboard {
transition-property: transform !important;
}
.phone-app--performance .lock-screen {
will-change: transform, opacity;
}
.phone-app--performance .lock-screen-leave-active {
transition-property: transform, opacity !important;
}
.phone-app--performance .sim-picker__glow,
.phone-app--performance .banking-app__aurora {
display: none;
}
/* Chromium 103 cannot resolve color-mix() when the mixed color is a variable. */
@supports not (color: color-mix(in srgb, white, black)) {
.flappy-menu__tower,
.flappy-obstacle span {
background: var(--tower);
}
.flappy-menu__tower::after {
border: 3px solid var(--tower);
background: var(--tower);
}
.flappy-obstacle span::after {
background: var(--tower);
}
}
.phone-display-dimmer {
position: absolute;
z-index: 99;
@@ -364,17 +439,25 @@ button {
top: 0;
left: 0;
width: 100%;
height: 45px;
padding: 19px 42px 0;
height: 48px;
padding: 17px 30px 0;
color: #fff;
display: flex;
justify-content: space-between;
align-items: flex-start;
font-size: 12px;
font-weight: 500;
align-items: center;
font-size: 17px;
font-weight: 600;
line-height: 20px;
letter-spacing: -0.25px;
text-shadow: 0 1px 3px #0009;
pointer-events: none;
}
.phone-status-bar__time {
position: absolute;
left: 0;
width: calc(50% - 70px);
text-align: center;
}
.phone-status-bar__indicators {
position: absolute;
top: 0;
@@ -382,10 +465,10 @@ button {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 3px;
width: 118px;
height: 45px;
padding: 11px 42px 0 0;
gap: 6px;
width: 120px;
height: 48px;
padding: 17px 30px 0 0;
border: 0;
color: inherit;
background: transparent;
@@ -481,13 +564,13 @@ button {
.lock-screen__status {
position: absolute;
z-index: 1;
top: 11px;
right: 26px;
left: 26px;
top: 17px;
right: 30px;
left: 30px;
display: flex;
align-items: center;
justify-content: flex-end;
min-height: 18px;
min-height: 26px;
filter: drop-shadow(0 1px 2px #0009);
}
.lock-screen__lock {
@@ -503,7 +586,7 @@ button {
right: 0;
display: flex;
align-items: center;
gap: 3px;
gap: 6px;
}
.lock-screen__content {
position: relative;
@@ -5980,6 +6063,7 @@ button {
}
.messages-inbox-page .messages-conversation:active,
.messages-inbox-page .messages-conversation--selected {
background: var(--messages-inbox-control);
background: color-mix(
in srgb,
var(--messages-inbox-control) 86%,
@@ -37,9 +37,19 @@ const wrapperStyle = computed<CSSProperties>(() => ({ zoom: props.zoom }))
>
<section
class="phone-device phone-device--notification"
:class="{
'phone-app--light': !isDarkMode,
[`phone-app--${preferences.settings.graphicsMode}`]: true,
}"
:aria-label="device.name"
>
<div class="phone-screen">
<div
class="phone-screen"
:class="{
'phone-app--light': !isDarkMode,
[`phone-app--${preferences.settings.graphicsMode}`]: true,
}"
>
<k-app
theme="ios"
:dark="isDarkMode"
@@ -48,6 +58,7 @@ const wrapperStyle = computed<CSSProperties>(() => ({ zoom: props.zoom }))
:class="{
dark: isDarkMode,
'phone-app--light': !isDarkMode,
[`phone-app--${preferences.settings.graphicsMode}`]: true,
}"
>
<div
+5 -14
View File
@@ -1,15 +1,9 @@
<script setup lang="ts">
import { kFab } from 'konsta/vue'
import {
BatteryMedium,
Camera,
Flashlight,
LockKeyhole,
Signal,
Wifi,
} from 'lucide-vue-next'
import { Camera, Flashlight, LockKeyhole } from 'lucide-vue-next'
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import PhoneStatusIndicators from '@/components/PhoneStatusIndicators.vue'
import { usePhoneStore } from '@/stores/phone'
import { nuiCall } from '@/utils/nui'
@@ -41,7 +35,8 @@ const date = computed(() =>
)
const time = computed(() =>
new Intl.DateTimeFormat(phone.lang, {
hour: 'numeric',
hour: '2-digit',
hourCycle: 'h23',
minute: '2-digit',
})
.formatToParts(now.value)
@@ -140,11 +135,7 @@ onBeforeUnmount(() => {
<div class="lock-screen__shade" aria-hidden="true"></div>
<header class="lock-screen__status">
<div class="lock-screen__indicators" aria-hidden="true">
<Signal :size="12" :stroke-width="2.5" />
<Wifi :size="13" :stroke-width="2.5" />
<BatteryMedium :size="17" :stroke-width="2.4" />
</div>
<PhoneStatusIndicators class="lock-screen__indicators" />
</header>
<LockKeyhole
class="lock-screen__lock"
+8 -24
View File
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { BatteryMedium, Plane, Signal, Wifi } from 'lucide-vue-next'
import { onBeforeUnmount, onMounted, ref } from 'vue'
import PhoneStatusIndicators from '@/components/PhoneStatusIndicators.vue'
import { usePhoneStore } from '@/stores/phone'
const phone = usePhoneStore()
@@ -13,7 +13,8 @@ let intervalId: number | undefined
function updateTime(): void {
time.value = new Intl.DateTimeFormat([], {
hour: 'numeric',
hour: '2-digit',
hourCycle: 'h23',
minute: '2-digit',
}).format(new Date())
}
@@ -30,7 +31,7 @@ onBeforeUnmount(() => {
<template>
<header class="phone-status-bar" :aria-label="phone.t('Common.phoneStatus')">
<time>{{ time }}</time>
<time class="phone-status-bar__time">{{ time }}</time>
<button
class="phone-status-bar__indicators"
type="button"
@@ -38,28 +39,11 @@ onBeforeUnmount(() => {
:aria-expanded="controlCenterOpened"
@click.stop="emit('controlCenter')"
>
<Plane
v-if="phone.preferences.settings.airplaneMode"
:size="12"
:stroke-width="2.5"
aria-hidden="true"
<PhoneStatusIndicators
:airplane-mode="phone.preferences.settings.airplaneMode"
:cellular-enabled="phone.preferences.settings.cellularEnabled"
:wifi-enabled="phone.preferences.settings.wifiEnabled"
/>
<Signal
v-else-if="phone.preferences.settings.cellularEnabled"
:size="12"
:stroke-width="2.5"
aria-hidden="true"
/>
<Wifi
v-if="
phone.preferences.settings.wifiEnabled &&
!phone.preferences.settings.airplaneMode
"
:size="13"
:stroke-width="2.5"
aria-hidden="true"
/>
<BatteryMedium :size="16" :stroke-width="2.4" />
</button>
</header>
</template>
@@ -0,0 +1,99 @@
<script setup lang="ts">
import { Plane } from 'lucide-vue-next'
withDefaults(
defineProps<{
airplaneMode?: boolean
cellularEnabled?: boolean
wifiEnabled?: boolean
}>(),
{
airplaneMode: false,
cellularEnabled: true,
wifiEnabled: true,
},
)
</script>
<template>
<span class="phone-status-indicators" aria-hidden="true">
<Plane
v-if="airplaneMode"
:size="18"
:stroke-width="2.6"
fill="currentColor"
/>
<svg
v-else-if="cellularEnabled"
class="phone-status-indicators__signal"
viewBox="0 0 20 16"
>
<rect x="0" y="11" width="3.5" height="5" rx="1.75" />
<rect x="5.5" y="8" width="3.5" height="8" rx="1.75" />
<rect x="11" y="4" width="3.5" height="12" rx="1.75" />
<rect x="16.5" width="3.5" height="16" rx="1.75" opacity="0.42" />
</svg>
<svg
v-if="wifiEnabled && !airplaneMode"
class="phone-status-indicators__wifi"
viewBox="0 0 22 17"
fill="none"
>
<path d="M1.5 5.4a14.2 14.2 0 0 1 19 0" />
<path d="M5 9.2a9 9 0 0 1 12 0" />
<path d="M8.5 13a3.8 3.8 0 0 1 5 0" />
<circle cx="11" cy="15.3" r="1.45" />
</svg>
<svg class="phone-status-indicators__battery" viewBox="0 0 29 16">
<rect x="0.75" y="0.75" width="25" height="14.5" rx="4" />
<rect x="3" y="3" width="14.5" height="10" rx="2.25" />
<rect x="26.6" y="5" width="2.4" height="6" rx="1.2" />
</svg>
</span>
</template>
<style scoped>
.phone-status-indicators {
display: flex;
align-items: center;
gap: 5px;
line-height: 0;
}
.phone-status-indicators > svg {
display: block;
flex: 0 0 auto;
overflow: visible;
}
.phone-status-indicators__signal {
width: 18px;
height: 14px;
fill: currentColor;
}
.phone-status-indicators__wifi {
width: 19px;
height: 15px;
stroke: currentColor;
stroke-width: 2.2;
stroke-linecap: round;
}
.phone-status-indicators__wifi circle {
fill: currentColor;
stroke: none;
}
.phone-status-indicators__battery {
width: 25px;
height: 14px;
fill: currentColor;
}
.phone-status-indicators__battery rect:first-child {
fill: none;
stroke: currentColor;
stroke-width: 1.5;
}
</style>
+324
View File
@@ -0,0 +1,324 @@
<script setup lang="ts">
import { Headphones } from 'lucide-vue-next'
import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
import type { RadioHudConfig, RadioHudMember } from '@/types/radio'
type RadioHudEntry = RadioHudMember & {
state: 'recent' | 'talking'
}
type RadioHudMessage = {
data?: Partial<RadioHudConfig> | { members?: RadioHudMember[] }
type?: string
}
const config = reactive<RadioHudConfig>({
enabled: false,
horizontal: 'right',
horizontalOffset: 2,
speakerPersistMilliseconds: 3000,
vertical: 'top',
verticalOffset: 30,
})
const entries = ref(new Map<number, RadioHudEntry>())
const removalTimers = new Map<number, number>()
const visibleEntries = computed(() =>
Array.from(entries.value.values()).sort((left, right) =>
left.name.localeCompare(right.name),
),
)
const positionStyle = computed(() => ({
'--radio-hud-horizontal-offset': `${config.horizontalOffset}vh`,
'--radio-hud-vertical-offset': `${config.verticalOffset}vh`,
}))
function clearRemovalTimer(id: number): void {
const timer = removalTimers.get(id)
if (timer !== undefined) window.clearTimeout(timer)
removalTimers.delete(id)
}
function setEntry(entry: RadioHudEntry): void {
const next = new Map(entries.value)
next.set(entry.id, entry)
entries.value = next
}
function removeEntry(id: number): void {
clearRemovalTimer(id)
const next = new Map(entries.value)
next.delete(id)
entries.value = next
}
function clearEntries(): void {
for (const timer of removalTimers.values()) window.clearTimeout(timer)
removalTimers.clear()
entries.value = new Map()
}
function scheduleRemoval(id: number): void {
clearRemovalTimer(id)
const duration = Math.max(
0,
Math.min(10_000, config.speakerPersistMilliseconds),
)
removalTimers.set(
id,
window.setTimeout(() => {
if (entries.value.get(id)?.state === 'recent') removeEntry(id)
}, duration),
)
}
function updateMembers(members: RadioHudMember[]): void {
const currentIds = new Set<number>()
for (const member of members) {
if (!Number.isInteger(member.id) || member.id <= 0) continue
currentIds.add(member.id)
const existing = entries.value.get(member.id)
const normalized = {
badge: String(member.badge ?? ''),
channel: member.channel === 2 ? 2 : 1,
id: member.id,
name: String(member.name || `ID ${member.id}`),
talking: member.talking === true,
} satisfies RadioHudMember
if (normalized.talking) {
clearRemovalTimer(member.id)
setEntry({ ...normalized, state: 'talking' })
} else if (existing?.state === 'talking') {
setEntry({ ...normalized, state: 'recent' })
scheduleRemoval(member.id)
} else if (existing) {
setEntry({ ...normalized, state: existing.state })
}
}
for (const id of entries.value.keys()) {
if (!currentIds.has(id)) removeEntry(id)
}
}
function updateConfig(value: Partial<RadioHudConfig>): void {
config.enabled = value.enabled === true
config.horizontal = value.horizontal === 'left' ? 'left' : 'right'
config.vertical = value.vertical === 'bottom' ? 'bottom' : 'top'
config.horizontalOffset = Math.max(
0,
Math.min(100, Number(value.horizontalOffset) || 0),
)
config.verticalOffset = Math.max(
0,
Math.min(100, Number(value.verticalOffset) || 0),
)
config.speakerPersistMilliseconds = Math.max(
0,
Math.min(10_000, Number(value.speakerPersistMilliseconds) || 0),
)
if (!config.enabled) clearEntries()
}
function onMessage(event: MessageEvent<RadioHudMessage>): void {
if (event.data?.type === 'radio:hud-config' && event.data.data) {
updateConfig(event.data.data as Partial<RadioHudConfig>)
} else if (event.data?.type === 'radio:hud-update' && event.data.data) {
const data = event.data.data as { members?: RadioHudMember[] }
updateMembers(Array.isArray(data.members) ? data.members : [])
}
}
onMounted(() => {
window.addEventListener('message', onMessage)
if (
import.meta.env.DEV &&
new URLSearchParams(window.location.search).has('radioHudPreview')
) {
updateConfig({
enabled: true,
horizontal: 'right',
horizontalOffset: 2,
speakerPersistMilliseconds: 3000,
vertical: 'top',
verticalOffset: 30,
})
updateMembers([
{
badge: '231',
channel: 1,
id: 21,
name: 'Unit 21',
talking: true,
},
{
badge: '12',
channel: 2,
id: 12,
name: 'Unit 12',
talking: true,
},
])
}
})
onBeforeUnmount(() => {
window.removeEventListener('message', onMessage)
clearEntries()
})
</script>
<template>
<aside
v-if="config.enabled"
class="radio-hud"
:data-horizontal="config.horizontal"
:data-vertical="config.vertical"
:style="positionStyle"
aria-hidden="true"
>
<TransitionGroup
name="radio-hud-member"
tag="div"
class="radio-hud__members"
>
<div
v-for="entry in visibleEntries"
:key="entry.id"
class="radio-hud__member"
:class="[
`radio-hud__member--${entry.state}`,
{ 'radio-hud__member--secondary': entry.channel === 2 },
]"
>
<Headphones class="radio-hud__icon" aria-hidden="true" />
<span v-if="entry.badge" class="radio-hud__badge">
[{{ entry.badge }}]
</span>
<span class="radio-hud__name">{{ entry.name }}</span>
</div>
</TransitionGroup>
</aside>
</template>
<style scoped>
.radio-hud {
position: fixed;
z-index: 40;
display: flex;
pointer-events: none;
font-family: Inter, ui-sans-serif, system-ui, sans-serif;
}
.radio-hud[data-horizontal='left'] {
right: auto;
left: var(--radio-hud-horizontal-offset);
justify-content: flex-start;
}
.radio-hud[data-horizontal='right'] {
right: var(--radio-hud-horizontal-offset);
left: auto;
justify-content: flex-end;
}
.radio-hud[data-vertical='bottom'] {
top: auto;
bottom: var(--radio-hud-vertical-offset);
}
.radio-hud[data-vertical='top'] {
top: var(--radio-hud-vertical-offset);
bottom: auto;
}
.radio-hud__members {
display: flex;
flex-direction: column;
gap: 0.6vh;
align-items: flex-end;
}
.radio-hud[data-horizontal='left'] .radio-hud__members {
align-items: flex-start;
}
.radio-hud__member {
display: flex;
max-width: 32vw;
align-items: center;
gap: 0.7vh;
color: #4ade80;
font-size: clamp(12px, 1.25vh, 16px);
font-weight: 700;
line-height: 1;
filter: drop-shadow(0 2px 4px rgb(0 0 0 / 80%));
}
.radio-hud__member--secondary {
color: #facc15;
}
.radio-hud__member--recent {
color: rgb(255 255 255 / 90%);
}
.radio-hud__icon {
width: 1.8vh;
min-width: 14px;
height: 1.8vh;
min-height: 14px;
animation: radio-hud-pulse 0.8s ease-in-out infinite;
filter: drop-shadow(0 0 6px currentColor);
}
.radio-hud__member--recent .radio-hud__icon {
animation: none;
filter: none;
}
.radio-hud__badge {
opacity: 0.75;
white-space: nowrap;
letter-spacing: 0.03em;
}
.radio-hud__name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
letter-spacing: 0.02em;
}
.radio-hud-member-enter-active,
.radio-hud-member-leave-active {
transition:
opacity 0.3s ease,
transform 0.3s cubic-bezier(0.22, 1, 0.36, 1);
}
.radio-hud-member-enter-from,
.radio-hud-member-leave-to {
opacity: 0;
transform: translateX(2vh);
}
.radio-hud[data-horizontal='left'] .radio-hud-member-enter-from,
.radio-hud[data-horizontal='left'] .radio-hud-member-leave-to {
transform: translateX(-2vh);
}
@keyframes radio-hud-pulse {
0%,
100% {
opacity: 1;
transform: scale(1);
}
50% {
opacity: 0.55;
transform: scale(0.86);
}
}
</style>
+5 -1
View File
@@ -48,7 +48,11 @@ function close(): void {
</script>
<template>
<div class="sim-picker-backdrop" @click.self="close">
<div
class="sim-picker-backdrop"
:class="`phone-app--${phone.preferences.settings.graphicsMode}`"
@click.self="close"
>
<section class="sim-picker" aria-modal="true" role="dialog">
<div class="sim-picker__glow sim-picker__glow--top" />
<div class="sim-picker__glow sim-picker__glow--bottom" />
@@ -154,6 +154,7 @@ function avatar(name: string): string {
function formatForecastHour(timestamp: number): string {
return new Intl.DateTimeFormat(phone.lang, {
hour: '2-digit',
hourCycle: 'h23',
minute: '2-digit',
}).format(timestamp)
}
+30
View File
@@ -1,8 +1,18 @@
import blackFrame from '@/assets/img/frames/black.webp'
import blueFrame from '@/assets/img/frames/blue.webp'
import cyanFrame from '@/assets/img/frames/cyan.webp'
import goldFrame from '@/assets/img/frames/gold.webp'
import greenFrame from '@/assets/img/frames/green.webp'
import lavenderFrame from '@/assets/img/frames/lavender.webp'
import limeFrame from '@/assets/img/frames/lime.webp'
import orangeFrame from '@/assets/img/frames/orange.webp'
import pinkFrame from '@/assets/img/frames/pink.webp'
import purpleFrame from '@/assets/img/frames/purple.webp'
import redFrame from '@/assets/img/frames/red.webp'
import rgbFrame from '@/assets/img/frames/rgb.webp'
import tealFrame from '@/assets/img/frames/teal.webp'
import whiteFrame from '@/assets/img/frames/white.webp'
import yellowFrame from '@/assets/img/frames/yellow.webp'
import type { PhoneFrameId } from '@/utils/preferences'
export const PHONE_FRAME_IMAGES: Record<PhoneFrameId, string> = {
@@ -10,7 +20,17 @@ export const PHONE_FRAME_IMAGES: Record<PhoneFrameId, string> = {
blue: blueFrame,
green: greenFrame,
lavender: lavenderFrame,
red: redFrame,
white: whiteFrame,
orange: orangeFrame,
yellow: yellowFrame,
lime: limeFrame,
teal: tealFrame,
cyan: cyanFrame,
purple: purpleFrame,
pink: pinkFrame,
gold: goldFrame,
rgb: rgbFrame,
}
export const PHONE_FRAME_COLORS: Record<PhoneFrameId, string> = {
@@ -18,5 +38,15 @@ export const PHONE_FRAME_COLORS: Record<PhoneFrameId, string> = {
blue: '#7294c2',
green: '#889b6e',
lavender: '#aaa1c8',
red: '#d93f45',
white: '#f2f2f2',
orange: '#e68a3f',
yellow: '#e5d134',
lime: '#75d13d',
teal: '#36a992',
cyan: '#35bfe3',
purple: '#8251df',
pink: '#db82b0',
gold: '#d4aa45',
rgb: 'conic-gradient(#ff3b30, #ffcc00, #34c759, #00c7be, #007aff, #af52de, #ff2d55, #ff3b30)',
}
+7
View File
@@ -46,6 +46,12 @@ describe('app registry', () => {
labelKey: 'Apps.calendar.name',
route: '/apps/calendar',
})
expect(PHONE_APPS.find((app) => app.id === 'radio')).toMatchObject({
dockOrder: null,
gridOrder: 21,
labelKey: 'Apps.radio.name',
route: '/apps/radio',
})
expect(PHONE_APPS.find((app) => app.id === 'snake')).toMatchObject({
dockOrder: null,
gridOrder: 12,
@@ -120,6 +126,7 @@ describe('app registry', () => {
),
).toEqual([
'fliptok',
'radio',
'local-pages',
'phone',
'darkchat',
+16
View File
@@ -17,6 +17,7 @@ import {
ShieldCheck,
NotebookPen,
Phone,
RadioTower,
Settings,
ShoppingBag,
CloudSun,
@@ -37,6 +38,7 @@ import mapIcon from '@/assets/img/app-icons/map.webp'
import messagesIcon from '@/assets/img/app-icons/sms.webp'
import darkChatIcon from '@/assets/img/app-icons/darkchat.webp'
import notesIcon from '@/assets/img/app-icons/notes.webp'
import radioIcon from '@/assets/img/app-icons/radio.svg'
import photosIcon from '@/assets/img/app-icons/gallery.webp'
import phoneIcon from '@/assets/img/app-icons/phone.webp'
import settingsIcon from '@/assets/img/app-icons/settings.svg'
@@ -88,6 +90,20 @@ export const PHONE_APPS: PhoneAppDefinition[] = [
labelKey: 'Apps.calendar.name',
route: '/apps/calendar',
},
{
category: 'social',
component: markRaw(
defineAsyncComponent(() => import('@/views/apps/RadioApp.vue')),
),
dockOrder: null,
gridOrder: 21,
icon: markRaw(RadioTower),
iconClass: 'app-icon--radio',
iconImage: radioIcon,
id: 'radio',
labelKey: 'Apps.radio.name',
route: '/apps/radio',
},
{
category: 'social',
component: markRaw(
+1 -1
View File
@@ -49,7 +49,7 @@ export function useClockService() {
time: computed(() =>
new Intl.DateTimeFormat(phone.lang, {
hour: '2-digit',
hour12: false,
hourCycle: 'h23',
minute: '2-digit',
}).format(now.value),
),
+73
View File
@@ -464,6 +464,62 @@ const defaultLocales: LocaleTree = {
default: 'The phone request failed.',
},
},
radio: {
name: 'Radio',
disconnected: 'Not connected',
connectedTo: 'Connected - {frequency} MHz',
noProvider: 'Voice service unavailable',
channel: 'Channel',
primaryFrequency: 'Primary frequency',
secondaryFrequency: 'Secondary frequency',
frequencyPlaceholder: 'e.g. 120.5',
optional: 'Optional',
mhz: 'MHz',
volume: 'Volume',
connect: 'Connect',
disconnect: 'Disconnect',
members: 'Currently connected ({count})',
noMembers: 'No participants',
history: 'Recently connected',
noHistory: 'No history',
badge: 'Service number',
badgePlaceholder: 'e.g. 231',
profileSaved: 'Radio profile saved',
displayName: 'Radio display name',
displayNamePlaceholder: 'Leave empty to use your character name',
displayNameDescription:
'This name is shown to other participants and in the built-in radio overlay.',
displayNameNotAllowed:
'Your current job or grade is not allowed to change the radio display name.',
otherSettings: 'Other',
autoRejoin: 'Automatic rejoin',
autoRejoinDescription:
'Reconnect after spawning or restarting the phone resource',
radioNotifications: 'Radio notifications',
notificationsDescription: 'Show joins and leaves on your current channel',
memberJoined: '{name} joined the radio',
memberLeft: '{name} left the radio',
unknownMember: 'Unknown',
tabs: { radio: 'Radio', settings: 'Settings' },
errors: {
invalid_frequency: 'Enter a valid frequency.',
invalid_volume: 'Enter a valid volume.',
channel_locked: 'You do not have access to this channel.',
secondary_locked: 'You do not have access to the secondary channel.',
voice_unavailable: 'The configured radio voice service is unavailable.',
player_unavailable: 'Your player data is not available.',
rate_limited: 'Please wait before changing channel again.',
invalid_setting: 'This setting is invalid.',
badge_disabled: 'Service numbers are disabled.',
badge_forbidden: 'This service number is not allowed.',
display_name_disabled: 'Radio display names are disabled.',
display_name_forbidden:
'Your job or grade cannot change the radio display name.',
invalid_display_name: 'Enter a valid radio display name.',
request_failed: 'The radio request failed.',
default: 'The radio request failed.',
},
},
banking: {
name: 'Banking',
welcome: 'Welcome back',
@@ -1534,6 +1590,13 @@ const defaultLocales: LocaleTree = {
notificationVolume: 'Notification Volume',
ringtone: 'Ringtone',
notificationSound: 'Notification Sound',
graphicsMode: 'Graphics Mode',
performanceMode: 'Performance Mode',
performanceModeDescription:
'Blur-free glass simulation for better CEF performance',
ultimateMode: 'Ultimate Mode',
ultimateModeDescription:
'Full blur and glass effects for compatible FiveM clients',
appearanceMode: 'Appearance Mode',
automatic: 'Automatic',
light: 'Light',
@@ -1621,7 +1684,17 @@ const defaultLocales: LocaleTree = {
blue: 'Blue',
green: 'Green',
lavender: 'Lavender',
red: 'Red',
white: 'White',
orange: 'Orange',
yellow: 'Yellow',
lime: 'Lime',
teal: 'Teal',
cyan: 'Cyan',
purple: 'Purple',
pink: 'Pink',
gold: 'Gold',
rgb: 'RGB',
},
ringtones: {
skyline: 'Skyline',
+142
View File
@@ -0,0 +1,142 @@
import { reactive, ref } from 'vue'
import { defineStore } from 'pinia'
import type { RadioData, RadioMember, RadioSettings } from '@/types/radio'
import { nuiCall } from '@/utils/nui'
const defaults: RadioData = {
badge: '',
badgeEnabled: true,
badgeMaxLength: 8,
connected: false,
displayName: '',
displayNameAllowed: false,
displayNameEnabled: true,
displayNameMaxLength: 32,
frequency: 0,
frequencyMax: 999.9,
frequencyMin: 0.1,
frequencyStep: 0.1,
history: [],
members: [],
provider: null,
secondaryFrequency: 0,
secondarySupported: true,
settings: { autoRejoin: false, notifications: false },
volume: 50,
}
export const useRadioStore = defineStore('radio', () => {
const data = reactive<RadioData>(structuredClone(defaults))
const error = ref('')
const isLoading = ref(false)
function apply(next: Partial<RadioData>): void {
Object.assign(data, next)
}
async function load(): Promise<void> {
isLoading.value = true
error.value = ''
const response = await nuiCall<RadioData>('radio:get')
if (response.success && response.data) apply(response.data)
else error.value = response.error ?? 'request_failed'
isLoading.value = false
}
async function connect(
frequency: number,
secondaryFrequency: number,
): Promise<boolean> {
isLoading.value = true
error.value = ''
const response = await nuiCall<Partial<RadioData>>('radio:connect', {
frequency,
secondaryFrequency,
})
if (response.success && response.data) apply(response.data)
else error.value = response.error ?? 'request_failed'
isLoading.value = false
return response.success
}
async function disconnect(): Promise<void> {
error.value = ''
const response = await nuiCall('radio:disconnect')
if (!response.success) {
error.value = response.error ?? 'request_failed'
return
}
apply({
connected: false,
frequency: 0,
members: [],
secondaryFrequency: 0,
})
}
async function setVolume(volume: number): Promise<void> {
data.volume = volume
const response = await nuiCall<{ volume: number }>('radio:set-volume', {
volume,
})
if (response.success && response.data) data.volume = response.data.volume
}
async function saveSetting(
key: keyof RadioSettings,
value: boolean,
): Promise<void> {
const previous = data.settings[key]
data.settings[key] = value
const response = await nuiCall<RadioSettings>('radio:save-settings', {
key,
value,
})
if (response.success && response.data) data.settings = response.data
else {
data.settings[key] = previous
error.value = response.error ?? 'request_failed'
}
}
async function saveBadge(badge: string): Promise<boolean> {
error.value = ''
const response = await nuiCall<{ badge: string }>('radio:save-badge', {
badge,
})
if (response.success && response.data) data.badge = response.data.badge
else error.value = response.error ?? 'request_failed'
return response.success
}
async function saveDisplayName(displayName: string): Promise<boolean> {
error.value = ''
const response = await nuiCall<{ displayName: string }>(
'radio:save-display-name',
{ displayName },
)
if (response.success && response.data)
data.displayName = response.data.displayName
else error.value = response.error ?? 'request_failed'
return response.success
}
function updateMembers(members: RadioMember[]): void {
data.members = members
}
return {
connect,
data,
disconnect,
error,
isLoading,
load,
saveBadge,
saveDisplayName,
saveSetting,
setVolume,
updateMembers,
}
})
+1
View File
@@ -14,6 +14,7 @@ export type PhoneAppId =
| 'mail'
| 'map'
| 'notes'
| 'radio'
| 'photos'
| 'app-store'
| 'settings'
+57
View File
@@ -0,0 +1,57 @@
export type RadioHistoryEntry = {
primary: number
secondary: number
}
export type RadioMember = {
badge: string
id: number
joinTime: number
name: string
rank: string
rankNumber: number
}
export type RadioHudConfig = {
enabled: boolean
horizontal: 'left' | 'right'
horizontalOffset: number
speakerPersistMilliseconds: number
vertical: 'bottom' | 'top'
verticalOffset: number
}
export type RadioHudMember = {
badge: string
channel: 1 | 2
id: number
name: string
talking: boolean
}
export type RadioSettings = {
autoRejoin: boolean
notifications: boolean
}
export type RadioData = {
badge: string
badgeEnabled: boolean
badgeMaxLength: number
connected: boolean
displayName: string
displayNameAllowed: boolean
displayNameEnabled: boolean
displayNameMaxLength: number
frequency: number
frequencyMax: number
frequencyMin: number
frequencyStep: number
history: RadioHistoryEntry[]
members: RadioMember[]
provider: string | null
secondaryFrequency: number
secondarySupported: boolean
settings: RadioSettings
volume: number
}
+8 -1
View File
@@ -16,6 +16,8 @@ describe('preferences', () => {
bluetoothEnabled: false,
cellularEnabled: false,
focusMode: true,
frame: 'rgb',
graphicsMode: 'ultimate',
notificationVolume: 45,
notificationDurationSeconds: 14,
notifications: {
@@ -34,6 +36,8 @@ describe('preferences', () => {
expect(value.settings.bluetoothEnabled).toBe(false)
expect(value.settings.cellularEnabled).toBe(false)
expect(value.settings.focusMode).toBe(true)
expect(value.settings.frame).toBe('rgb')
expect(value.settings.graphicsMode).toBe('ultimate')
expect(value.settings.notificationVolume).toBe(45)
expect(value.settings.notificationDurationSeconds).toBe(14)
expect(value.settings.notifications.messages).toEqual({
@@ -61,7 +65,8 @@ describe('preferences', () => {
version: 1,
settings: {
appearanceMode: 'neon',
frame: 'gold',
frame: 'bronze',
graphicsMode: 'cinematic',
notificationVolume: -10,
notificationDurationSeconds: 100,
phoneScale: 500,
@@ -73,6 +78,7 @@ describe('preferences', () => {
expect(value.settings.appearanceMode).toBe('automatic')
expect(value.settings.frame).toBe('black')
expect(value.settings.graphicsMode).toBe('performance')
expect(value.settings.notificationVolume).toBe(0)
expect(value.settings.notificationDurationSeconds).toBe(30)
expect(value.settings.phoneScale).toBe(150)
@@ -90,6 +96,7 @@ describe('preferences', () => {
bluetoothEnabled: true,
cellularEnabled: true,
focusMode: false,
graphicsMode: 'performance',
rotationLocked: false,
screenBrightness: 100,
wifiEnabled: true,
+20
View File
@@ -2,12 +2,23 @@ import type { LaunchablePhoneAppId } from '@/types/apps'
import { cloneJsonData } from '@/utils/clone'
export const APPEARANCE_MODE_IDS = ['automatic', 'light', 'dark'] as const
export const GRAPHICS_MODE_IDS = ['performance', 'ultimate'] as const
export const PHONE_FRAME_IDS = [
'black',
'blue',
'green',
'lavender',
'red',
'white',
'orange',
'yellow',
'lime',
'teal',
'cyan',
'purple',
'pink',
'gold',
'rgb',
] as const
export const RINGTONE_IDS = ['skyline', 'horizon', 'pulse'] as const
export const NOTIFICATION_SOUND_IDS = ['chime', 'signal', 'soft'] as const
@@ -17,6 +28,7 @@ export const PHONE_SCALE_MAX = 150
export const PHONE_SCALE_STEP = 5
export type AppearanceMode = (typeof APPEARANCE_MODE_IDS)[number]
export type GraphicsMode = (typeof GRAPHICS_MODE_IDS)[number]
export type PhoneFrameId = (typeof PHONE_FRAME_IDS)[number]
export type RingtoneId = (typeof RINGTONE_IDS)[number]
export type NotificationSoundId = (typeof NOTIFICATION_SOUND_IDS)[number]
@@ -34,6 +46,7 @@ export type PhonePreferencesV1 = {
cellularEnabled: boolean
focusMode: boolean
frame: PhoneFrameId
graphicsMode: GraphicsMode
notificationSound: NotificationSoundId
notificationDurationSeconds: number
notificationVolume: number
@@ -78,6 +91,7 @@ const DEFAULT_APP_NOTIFICATIONS: Record<
mail: { enabled: true, sounds: true },
map: { enabled: true, sounds: true },
notes: { enabled: true, sounds: true },
radio: { enabled: true, sounds: true },
photos: { enabled: true, sounds: true },
settings: { enabled: true, sounds: true },
}
@@ -90,6 +104,7 @@ export const DEFAULT_PHONE_PREFERENCES: PhonePreferencesV1 = {
cellularEnabled: true,
focusMode: false,
frame: 'black',
graphicsMode: 'performance',
notificationSound: 'chime',
notificationDurationSeconds: 10,
notificationVolume: 70,
@@ -187,6 +202,11 @@ export function parsePhonePreferences(raw: string | null): PhonePreferencesV1 {
),
focusMode: readBoolean(settings.focusMode, defaults.focusMode),
frame: readChoice(settings.frame, PHONE_FRAME_IDS, defaults.frame),
graphicsMode: readChoice(
settings.graphicsMode,
GRAPHICS_MODE_IDS,
defaults.graphicsMode,
),
notificationSound: readChoice(
settings.notificationSound,
NOTIFICATION_SOUND_IDS,
+1
View File
@@ -127,6 +127,7 @@ function formatDate(timestamp: number): string {
return new Intl.DateTimeFormat(phone.lang, {
day: 'numeric',
hour: '2-digit',
hourCycle: 'h23',
minute: '2-digit',
month: 'short',
}).format(timestamp)
+3
View File
@@ -236,6 +236,7 @@ function eventsForDay(day: Date): CalendarEvent[] {
function formatTime(value: number): string {
return new Intl.DateTimeFormat(phone.lang, {
hour: '2-digit',
hourCycle: 'h23',
minute: '2-digit',
}).format(value)
}
@@ -1584,6 +1585,7 @@ onMounted(async () => {
grid-template-columns: 1fr auto 1fr;
align-items: center;
border-bottom: 1px solid var(--line);
background: var(--bg);
background: color-mix(in srgb, var(--bg) 82%, transparent);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
@@ -1902,6 +1904,7 @@ onMounted(async () => {
border-bottom: 0;
border-radius: 28px 28px 0 0;
overflow: hidden;
background: var(--elevated);
background: color-mix(in srgb, var(--elevated) 94%, transparent);
box-shadow: 0 -18px 60px rgb(0 0 0 / 38%);
backdrop-filter: blur(30px) saturate(180%);
+1
View File
@@ -345,6 +345,7 @@ function relativeDate(value: DatabaseDateValue): string {
function messageTime(value: DatabaseDateValue): string {
return new Intl.DateTimeFormat(phone.lang, {
hour: '2-digit',
hourCycle: 'h23',
minute: '2-digit',
}).format(parseDatabaseDate(value))
}
+1
View File
@@ -81,6 +81,7 @@ const timerPicker = computed({
const currentTime = computed(() =>
new Intl.DateTimeFormat(phone.lang, {
hour: '2-digit',
hourCycle: 'h23',
minute: '2-digit',
timeZone: browserTimeZone,
}).format(now.value),
+1
View File
@@ -238,6 +238,7 @@ function formatDate(value: DatabaseDateValue): string {
if (date.toDateString() === today.toDateString()) {
return new Intl.DateTimeFormat(phone.lang, {
hour: '2-digit',
hourCycle: 'h23',
minute: '2-digit',
}).format(date)
}
+2
View File
@@ -185,6 +185,7 @@ function formatDate(value: string): string {
return new Intl.DateTimeFormat(phone.lang, {
day: 'numeric',
hour: '2-digit',
hourCycle: 'h23',
minute: '2-digit',
month: 'short',
}).format(date)
@@ -197,6 +198,7 @@ function formatListDate(value: string): string {
if (date.toDateString() === today.toDateString()) {
return new Intl.DateTimeFormat(phone.lang, {
hour: '2-digit',
hourCycle: 'h23',
minute: '2-digit',
}).format(date)
}
+2
View File
@@ -187,6 +187,7 @@ function formatConversationDate(value: DatabaseDateValue): string {
if (date.toDateString() === today.toDateString()) {
return new Intl.DateTimeFormat(phone.lang, {
hour: '2-digit',
hourCycle: 'h23',
minute: '2-digit',
}).format(date)
}
@@ -229,6 +230,7 @@ function timeLabel(value: DatabaseDateValue): string {
if (Number.isNaN(date.getTime())) return ''
return new Intl.DateTimeFormat(phone.lang, {
hour: '2-digit',
hourCycle: 'h23',
minute: '2-digit',
}).format(date)
}
+5 -1
View File
@@ -295,7 +295,11 @@ function togglePinned(): void {
<k-popover
:opened="menuOpened"
:target="menuTarget"
:class="{ dark: phone.isDarkMode }"
:class="{
dark: phone.isDarkMode,
'phone-app--light': !phone.isDarkMode,
[`phone-app--${phone.preferences.settings.graphicsMode}`]: true,
}"
angle
@backdropclick="menuOpened = false"
>
+563
View File
@@ -0,0 +1,563 @@
<script setup lang="ts">
import {
kBlock,
kBlockTitle,
kButton,
kList,
kListInput,
kListItem,
kNavbar,
kPage,
kPreloader,
kRange,
kSegmented,
kSegmentedButton,
kToast,
kToggle,
} from 'konsta/vue'
import {
Clock3,
RadioTower,
Settings,
Signal,
Users,
Volume2,
} from 'lucide-vue-next'
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { usePhoneStore } from '@/stores/phone'
import { useRadioStore } from '@/stores/radio'
import type { RadioHistoryEntry } from '@/types/radio'
type RadioTab = 'radio' | 'settings'
const phone = usePhoneStore()
const radio = useRadioStore()
const tab = ref<RadioTab>('radio')
const primaryInput = ref('')
const secondaryInput = ref('')
const badgeInput = ref('')
const displayNameInput = ref('')
const feedback = ref('')
const now = ref(Date.now())
const memberSnapshotAt = ref(Date.now())
let clockHandle: number | null = null
const statusText = computed(() => {
if (!radio.data.connected) return phone.t('Apps.radio.disconnected')
const secondary = radio.data.secondaryFrequency
? ` / ${radio.data.secondaryFrequency}`
: ''
return phone.t('Apps.radio.connectedTo', {
frequency: `${radio.data.frequency}${secondary}`,
})
})
function eventValue(event: Event): string {
return (event.target as HTMLInputElement).value
}
function parseFrequency(value: string): number {
return Number.parseFloat(value.replace(',', '.'))
}
function errorText(code: string): string {
return phone.t(`Apps.radio.errors.${code || 'default'}`)
}
async function connect(
primary = parseFrequency(primaryInput.value),
secondary = parseFrequency(secondaryInput.value) || 0,
): Promise<void> {
if (!Number.isFinite(primary)) {
radio.error = 'invalid_frequency'
return
}
const connected = await radio.connect(primary, secondary)
if (connected) {
memberSnapshotAt.value = Date.now()
primaryInput.value = String(radio.data.frequency)
secondaryInput.value = radio.data.secondaryFrequency
? String(radio.data.secondaryFrequency)
: ''
}
}
async function disconnect(): Promise<void> {
await radio.disconnect()
}
function connectHistory(entry: RadioHistoryEntry): void {
primaryInput.value = String(entry.primary)
secondaryInput.value = entry.secondary ? String(entry.secondary) : ''
void connect(entry.primary, entry.secondary)
}
function formatDuration(joinTime: number): string {
const seconds = Math.max(
0,
joinTime + Math.floor((now.value - memberSnapshotAt.value) / 1000),
)
const minutes = Math.floor(seconds / 60)
const hours = Math.floor(minutes / 60)
if (hours) return `${hours}h ${minutes % 60}m`
if (minutes) return `${minutes}m ${seconds % 60}s`
return `${seconds}s`
}
function normalizedBadge(): string {
const clean = badgeInput.value
.replace(/[^A-Za-z0-9_-]/g, '')
.slice(0, radio.data.badgeMaxLength)
badgeInput.value = clean
return clean
}
function normalizedDisplayName(): string {
const clean = Array.from(
displayNameInput.value
.replace(/[\u0000-\u001F\u007F]/g, '')
.replace(/\s+/g, ' ')
.trim(),
)
.slice(0, radio.data.displayNameMaxLength)
.join('')
displayNameInput.value = clean
return clean
}
async function saveRadioProfile(): Promise<void> {
if (radio.data.displayNameEnabled && radio.data.displayNameAllowed) {
const displayNameSaved = await radio.saveDisplayName(
normalizedDisplayName(),
)
if (!displayNameSaved) {
feedback.value = errorText(radio.error)
window.setTimeout(() => (feedback.value = ''), 2500)
return
}
}
if (radio.data.badgeEnabled) {
const badgeSaved = await radio.saveBadge(normalizedBadge())
if (!badgeSaved) {
feedback.value = errorText(radio.error)
window.setTimeout(() => (feedback.value = ''), 2500)
return
}
}
feedback.value = phone.t('Apps.radio.profileSaved')
window.setTimeout(() => (feedback.value = ''), 2500)
}
function onMessage(event: MessageEvent): void {
if (event.data?.type === 'radio:updated' && event.data.data?.members) {
memberSnapshotAt.value = Date.now()
radio.updateMembers(event.data.data.members)
}
}
onMounted(async () => {
window.addEventListener('message', onMessage)
clockHandle = window.setInterval(() => (now.value = Date.now()), 1000)
await radio.load()
memberSnapshotAt.value = Date.now()
badgeInput.value = radio.data.badge
displayNameInput.value = radio.data.displayName
if (radio.data.frequency) primaryInput.value = String(radio.data.frequency)
if (radio.data.secondaryFrequency)
secondaryInput.value = String(radio.data.secondaryFrequency)
})
onBeforeUnmount(() => {
window.removeEventListener('message', onMessage)
if (clockHandle) window.clearInterval(clockHandle)
})
</script>
<template>
<k-page component="main" class="radio-app">
<k-navbar :title="phone.t('Apps.radio.name')">
<template #subnavbar>
<k-segmented :key="tab" strong rounded class="radio-tabs">
<k-segmented-button :active="tab === 'radio'" @click="tab = 'radio'">
<RadioTower :size="17" />
{{ phone.t('Apps.radio.tabs.radio') }}
</k-segmented-button>
<k-segmented-button
:active="tab === 'settings'"
@click="tab = 'settings'"
>
<Settings :size="17" />
{{ phone.t('Apps.radio.tabs.settings') }}
</k-segmented-button>
</k-segmented>
</template>
</k-navbar>
<div v-if="radio.isLoading && !radio.data.provider" class="radio-loading">
<k-preloader />
{{ phone.t('Common.loading') }}
</div>
<template v-else-if="tab === 'radio'">
<k-block class="radio-status" strong inset>
<Signal :size="22" :class="{ 'radio-online': radio.data.connected }" />
<div>
<strong>{{ statusText }}</strong>
<small>{{
radio.data.provider ?? phone.t('Apps.radio.noProvider')
}}</small>
</div>
<i :class="{ 'radio-status-dot--online': radio.data.connected }"></i>
</k-block>
<k-block-title>{{ phone.t('Apps.radio.channel') }}</k-block-title>
<k-list strong inset>
<k-list-input
type="number"
:label="phone.t('Apps.radio.primaryFrequency')"
:placeholder="phone.t('Apps.radio.frequencyPlaceholder')"
:min="radio.data.frequencyMin"
:max="radio.data.frequencyMax"
:step="radio.data.frequencyStep"
:value="primaryInput"
@input="primaryInput = eventValue($event)"
>
<template #after>{{ phone.t('Apps.radio.mhz') }}</template>
</k-list-input>
<k-list-input
v-if="radio.data.secondarySupported"
type="number"
:label="phone.t('Apps.radio.secondaryFrequency')"
:placeholder="phone.t('Apps.radio.optional')"
:min="radio.data.frequencyMin"
:max="radio.data.frequencyMax"
:step="radio.data.frequencyStep"
:value="secondaryInput"
@input="secondaryInput = eventValue($event)"
>
<template #after>{{ phone.t('Apps.radio.mhz') }}</template>
</k-list-input>
<k-list-item :title="phone.t('Apps.radio.volume')">
<template #media><Volume2 :size="20" /></template>
<template #inner>
<div class="radio-volume">
<k-range
:value="radio.data.volume"
:min="0"
:max="100"
:step="1"
:aria-label="phone.t('Apps.radio.volume')"
@input="radio.setVolume(Number(eventValue($event)))"
/>
<span>{{ radio.data.volume }}%</span>
</div>
</template>
</k-list-item>
</k-list>
<k-block inset class="radio-action-block">
<k-button
v-if="!radio.data.connected"
large
rounded
:disabled="radio.isLoading"
@click="connect()"
>
{{ phone.t('Apps.radio.connect') }}
</k-button>
<k-button
v-else
large
rounded
class="radio-disconnect"
@click="disconnect"
>
{{ phone.t('Apps.radio.disconnect') }}
</k-button>
<p v-if="radio.error" class="radio-error">
{{ errorText(radio.error) }}
</p>
</k-block>
<template v-if="radio.data.connected">
<k-block-title>
<Users :size="16" />
{{
phone.t('Apps.radio.members', {
count: String(radio.data.members.length),
})
}}
</k-block-title>
<k-list strong inset>
<k-list-item
v-for="member in radio.data.members"
:key="member.id"
:title="member.name"
:subtitle="formatDuration(member.joinTime)"
:after="member.rank || String(member.rankNumber || '')"
/>
<k-list-item
v-if="!radio.data.members.length"
:title="phone.t('Apps.radio.noMembers')"
/>
</k-list>
</template>
<template v-else>
<k-block-title>
<Clock3 :size="16" />
{{ phone.t('Apps.radio.history') }}
</k-block-title>
<k-list strong inset>
<k-list-item
v-for="entry in radio.data.history"
:key="`${entry.primary}-${entry.secondary}`"
link
:title="`${entry.primary}${entry.secondary ? ` / ${entry.secondary}` : ''} ${phone.t('Apps.radio.mhz')}`"
@click="connectHistory(entry)"
/>
<k-list-item
v-if="!radio.data.history.length"
:title="phone.t('Apps.radio.noHistory')"
/>
</k-list>
</template>
</template>
<template v-else>
<template v-if="radio.data.displayNameEnabled">
<k-block-title class="radio-settings-title">
{{ phone.t('Apps.radio.displayName') }}
</k-block-title>
<k-list strong inset class="radio-settings-list">
<k-list-input
type="text"
:disabled="!radio.data.displayNameAllowed"
:maxlength="radio.data.displayNameMaxLength"
:placeholder="phone.t('Apps.radio.displayNamePlaceholder')"
:value="displayNameInput"
@input="displayNameInput = eventValue($event)"
/>
</k-list>
<k-block class="radio-hint-block">
<p class="radio-setting-hint">
{{
phone.t(
radio.data.displayNameAllowed
? 'Apps.radio.displayNameDescription'
: 'Apps.radio.displayNameNotAllowed',
)
}}
</p>
</k-block>
</template>
<template v-if="radio.data.badgeEnabled">
<k-block-title class="radio-settings-title">
{{ phone.t('Apps.radio.badge') }}
</k-block-title>
<k-list strong inset class="radio-settings-list">
<k-list-input
type="text"
:maxlength="radio.data.badgeMaxLength"
:placeholder="phone.t('Apps.radio.badgePlaceholder')"
:value="badgeInput"
@input="
badgeInput = eventValue($event).replace(/[^A-Za-z0-9_-]/g, '')
"
/>
</k-list>
</template>
<k-block
v-if="
radio.data.badgeEnabled ||
(radio.data.displayNameEnabled && radio.data.displayNameAllowed)
"
inset
class="radio-action-block radio-profile-action"
>
<k-button rounded large @click="saveRadioProfile">
{{ phone.t('Common.save') }}
</k-button>
</k-block>
<k-block-title class="radio-settings-title">
{{ phone.t('Apps.radio.otherSettings') }}
</k-block-title>
<k-list strong inset class="radio-settings-list">
<k-list-item
class="radio-setting-row"
:title="phone.t('Apps.radio.autoRejoin')"
:subtitle="phone.t('Apps.radio.autoRejoinDescription')"
>
<template #after>
<k-toggle
:checked="radio.data.settings.autoRejoin"
@change="
radio.saveSetting('autoRejoin', !radio.data.settings.autoRejoin)
"
/>
</template>
</k-list-item>
<k-list-item
class="radio-setting-row"
:title="phone.t('Apps.radio.radioNotifications')"
:subtitle="phone.t('Apps.radio.notificationsDescription')"
>
<template #after>
<k-toggle
:checked="radio.data.settings.notifications"
@change="
radio.saveSetting(
'notifications',
!radio.data.settings.notifications,
)
"
/>
</template>
</k-list-item>
</k-list>
</template>
<k-toast
:opened="Boolean(feedback)"
position="center"
@click="feedback = ''"
>
{{ feedback }}
</k-toast>
</k-page>
</template>
<style scoped>
.radio-app {
--radio-blue: #0a84ff;
overflow-y: auto;
padding-bottom: 34px;
}
.radio-tabs :deep(button) {
align-items: center;
display: flex;
gap: 6px;
justify-content: center;
}
.radio-loading {
align-items: center;
display: flex;
gap: 10px;
justify-content: center;
min-height: 240px;
}
.radio-status {
align-items: center;
display: grid;
gap: 12px;
grid-template-columns: auto 1fr auto;
margin-top: 18px;
}
.radio-status div {
display: flex;
flex-direction: column;
min-width: 0;
}
.radio-status small {
color: var(--k-color-subtitle, #8e8e93);
margin-top: 2px;
text-transform: capitalize;
}
.radio-status i {
background: #c7c7cc;
border-radius: 50%;
height: 10px;
width: 10px;
}
.radio-status .radio-status-dot--online {
background: #30d158;
}
.radio-online {
color: #30d158;
}
.radio-volume {
align-items: center;
display: grid;
gap: 12px;
grid-template-columns: 1fr 44px;
width: 100%;
}
.radio-volume span {
color: var(--k-color-subtitle, #8e8e93);
font-variant-numeric: tabular-nums;
text-align: right;
}
.radio-action-block {
padding-left: 0;
padding-right: 0;
}
.radio-setting-hint {
color: inherit;
font-size: 16px;
font-weight: 450;
line-height: 1.45;
margin: 0;
opacity: 0.82;
}
.radio-hint-block {
margin-bottom: 0;
margin-top: 8px;
}
.radio-settings-title {
margin-bottom: 6px;
margin-top: 16px;
}
.radio-settings-list {
margin-bottom: 0;
margin-top: 0;
}
.radio-profile-action {
margin-bottom: 0;
margin-top: 12px;
}
.radio-setting-row :deep(.text-sm) {
font-size: 15px;
line-height: 1.35;
}
.radio-error {
color: #ff3b30;
font-size: 13px;
margin: 10px 4px 0;
text-align: center;
}
.radio-disconnect {
background: #ff3b30 !important;
color: #fff !important;
}
:deep(.k-block-title) {
align-items: center;
display: flex;
gap: 6px;
}
</style>
+46 -4
View File
@@ -66,6 +66,7 @@ import { nuiCall } from '@/utils/nui'
import { formatPhoneNumber } from '@/utils/phone'
import {
APPEARANCE_MODE_IDS,
GRAPHICS_MODE_IDS,
NOTIFICATION_SOUND_IDS,
PHONE_FRAME_IDS,
PHONE_SCALE_MAX,
@@ -74,6 +75,7 @@ import {
RINGTONE_IDS,
WALLPAPER_IDS,
type AppearanceMode,
type GraphicsMode,
type NotificationSoundId,
type PhoneFrameId,
type RingtoneId,
@@ -111,7 +113,17 @@ type PasscodeFlow =
const FACTORY_RESET_DURATION_MS = 60_000
const FACTORY_RESET_CIRCUMFERENCE = 2 * Math.PI * 48
const FRAME_PICKER_WIDTH = 240
const FRAME_PICKER_HEIGHT = 140
const FRAME_PICKER_COLUMN_COUNT = 3
const FRAME_PICKER_SWATCH_SIZE = 40
const FRAME_PICKER_GRID_GAP = 20
const FRAME_PICKER_PADDING = 20
const FRAME_PICKER_ROW_COUNT = Math.ceil(
PHONE_FRAME_IDS.length / FRAME_PICKER_COLUMN_COUNT,
)
const FRAME_PICKER_HEIGHT =
FRAME_PICKER_PADDING * 2 +
FRAME_PICKER_ROW_COUNT * FRAME_PICKER_SWATCH_SIZE +
Math.max(0, FRAME_PICKER_ROW_COUNT - 1) * FRAME_PICKER_GRID_GAP
const FRAME_PICKER_INSET = 8
const FRAME_PICKER_GAP = 8
@@ -465,6 +477,10 @@ function selectAppearanceMode(mode: AppearanceMode): void {
phone.setPreference('appearanceMode', mode)
}
function selectGraphicsMode(mode: GraphicsMode): void {
phone.setPreference('graphicsMode', mode)
}
function selectFrame(frame: PhoneFrameId): void {
phone.setPreference('frame', frame)
framePickerOpened.value = false
@@ -1357,6 +1373,28 @@ onBeforeUnmount(() => {
</template>
<template v-else-if="activeView === 'appearance'">
<k-block-title>
{{ phone.t('Apps.settings.graphicsMode') }}
</k-block-title>
<k-list strong inset>
<k-list-item
v-for="mode in GRAPHICS_MODE_IDS"
:key="mode"
link
:chevron="false"
:title="phone.t(`Apps.settings.${mode}Mode`)"
:subtitle="phone.t(`Apps.settings.${mode}ModeDescription`)"
@click="selectGraphicsMode(mode)"
>
<template #after>
<Check
v-if="phone.preferences.settings.graphicsMode === mode"
class="w-5 h-5 text-primary"
/>
</template>
</k-list-item>
</k-list>
<k-block-title>
{{ phone.t('Apps.settings.appearanceMode') }}
</k-block-title>
@@ -1451,7 +1489,7 @@ onBeforeUnmount(() => {
<template #after>
<span
class="h-7 w-7 rounded-full border border-black/15 shadow-sm"
:style="{ backgroundColor: selectedFrameColor }"
:style="{ background: selectedFrameColor }"
aria-hidden="true"
/>
</template>
@@ -1463,7 +1501,11 @@ onBeforeUnmount(() => {
:opened="framePickerOpened"
:class="[
'settings-frame-popover !absolute !z-[200] !left-[var(--settings-frame-picker-left)] !top-[var(--settings-frame-picker-top)]',
{ dark: phone.isDarkMode },
{
dark: phone.isDarkMode,
'phone-app--light': !phone.isDarkMode,
[`phone-app--${phone.preferences.settings.graphicsMode}`]: true,
},
]"
@backdropclick="framePickerOpened = false"
>
@@ -1477,7 +1519,7 @@ onBeforeUnmount(() => {
:key="frame"
type="button"
class="h-10 w-10 rounded-full border border-black/15 shadow-sm"
:style="{ backgroundColor: PHONE_FRAME_COLORS[frame] }"
:style="{ background: PHONE_FRAME_COLORS[frame] }"
:aria-label="phone.t(`Apps.settings.frames.${frame}`)"
:aria-pressed="phone.preferences.settings.frame === frame"
@click="selectFrame(frame)"
+2 -1
View File
@@ -32,7 +32,8 @@ function conditionLabel(condition: WeatherConditionId): string {
function formatHour(timestamp: number, index: number): string {
if (index === 0) return phone.t('Apps.weather.now')
return new Intl.DateTimeFormat(phone.lang, {
hour: 'numeric',
hour: '2-digit',
hourCycle: 'h23',
timeZone: 'UTC',
}).format(timestamp)
}
+82
View File
@@ -20,6 +20,30 @@ function isoTime(offsetMilliseconds) {
let authenticated = true
let draft = null
const radioData = {
badge: '231',
badgeEnabled: true,
badgeMaxLength: 8,
connected: false,
displayName: 'Unit 21',
displayNameAllowed: true,
displayNameEnabled: true,
displayNameMaxLength: 32,
frequency: 0,
frequencyMax: 999.9,
frequencyMin: 0.1,
frequencyStep: 0.1,
history: [
{ primary: 120.5, secondary: 130.7 },
{ primary: 42.1, secondary: 0 },
],
members: [],
provider: 'yaca',
secondaryFrequency: 0,
secondarySupported: true,
settings: { autoRejoin: false, notifications: true },
volume: 50,
}
let mockBankBalance = 24787
let mockCashBalance = 2350
let nextBankTransactionId = 7
@@ -1173,6 +1197,64 @@ function counts() {
app.post('/api/:endpoint', (request, response) => {
console.log(`[NUI] ${request.params.endpoint}`, request.body)
const endpoint = request.params.endpoint
if (endpoint === 'radio:get') {
response.json({ success: true, data: radioData })
return
}
if (endpoint === 'radio:connect') {
radioData.connected = true
radioData.frequency = Number(request.body.frequency)
radioData.secondaryFrequency = Number(request.body.secondaryFrequency) || 0
radioData.members = [
{
id: 12,
joinTime: 248,
name: 'Alex Morgan',
rank: 'Sergeant',
rankNumber: 3,
},
{
id: 27,
joinTime: 42,
name: 'Jamie Rivera',
rank: 'Officer',
rankNumber: 1,
},
]
response.json({ success: true, data: radioData })
return
}
if (endpoint === 'radio:disconnect') {
radioData.connected = false
radioData.frequency = 0
radioData.secondaryFrequency = 0
radioData.members = []
response.json({ success: true })
return
}
if (endpoint === 'radio:set-volume') {
radioData.volume = Math.max(0, Math.min(100, Number(request.body.volume)))
response.json({ success: true, data: { volume: radioData.volume } })
return
}
if (endpoint === 'radio:save-settings') {
radioData.settings[request.body.key] = request.body.value === true
response.json({ success: true, data: radioData.settings })
return
}
if (endpoint === 'radio:save-badge') {
radioData.badge = String(request.body.badge ?? '')
response.json({ success: true, data: { badge: radioData.badge } })
return
}
if (endpoint === 'radio:save-display-name') {
radioData.displayName = String(request.body.displayName ?? '')
response.json({
success: true,
data: { displayName: radioData.displayName },
})
return
}
const bankingOverview = () => ({
bank: mockBankBalance,
cash: mockCashBalance,
+4
View File
@@ -8,8 +8,12 @@ export default defineConfig({
base: './',
build: {
assetsDir: 'assets',
// Keep the published NUI compatible with the Chromium 103 CEF runtime.
cssMinify: 'lightningcss',
cssTarget: 'chrome103',
emptyOutDir: true,
outDir: 'dist',
target: 'chrome103',
rollupOptions: {
output: {
assetFileNames: 'assets/sky-[name]-[hash].[ext]',
+50
View File
@@ -41,6 +41,56 @@ Config.Calls = {
RecentPageSize = 100,
}
Config.Radio = {
VoiceProvider = "auto", -- auto, yaca, pma, saltychat
DefaultVolume = 50,
HistoryLimit = 8,
FrequencyMin = 0.1,
FrequencyMax = 999.9,
FrequencyDecimals = 1,
AllowSecondary = true,
Notifications = false,
AutoRejoin = false,
DisplayName = {
Enabled = true,
MaxLength = 32,
AllowedJobs = { -- Job name = minimum grade. Unlisted jobs cannot set a radio display name.
police = 0,
sheriff = 0,
fib = 0,
army = 0,
ambulance = 0,
},
},
Hud = {
Enabled = true,
SpeakerPersistMilliseconds = 3000,
Position = {
Horizontal = "right", -- left or right
Vertical = "top", -- top or bottom
HorizontalOffset = 2.0, -- vh
VerticalOffset = 30.0, -- vh
},
},
Badge = {
Enabled = true,
MaxLength = 8,
ForbiddenPatterns = { "88", "1488", "18", "14", "28", "198" },
},
LockedChannels = {
{
range = { 0.1, 100.0 },
jobs = {
police = true,
sheriff = true,
fib = true,
army = true,
ambulance = true,
},
},
},
}
Config.Animations = {
Enabled = true,
PropModel = "prop_npc_phone_02",
+36 -2
View File
@@ -179,6 +179,33 @@ Locales["en"] = {
default = "The phone request failed.",
},
},
radio = {
name = "Radio", disconnected = "Not connected", connectedTo = "Connected - {frequency} MHz",
noProvider = "Voice service unavailable", channel = "Channel", primaryFrequency = "Primary frequency",
secondaryFrequency = "Secondary frequency", frequencyPlaceholder = "e.g. 120.5", optional = "Optional",
mhz = "MHz", volume = "Volume", connect = "Connect", disconnect = "Disconnect",
members = "Currently connected ({count})", noMembers = "No participants", history = "Recently connected",
noHistory = "No history", badge = "Service number", badgePlaceholder = "e.g. 231",
profileSaved = "Radio profile saved", otherSettings = "Other", autoRejoin = "Automatic rejoin",
displayName = "Radio display name", displayNamePlaceholder = "Leave empty to use your character name",
displayNameDescription = "This name is shown to other participants and in the built-in radio overlay.",
displayNameNotAllowed = "Your current job or grade is not allowed to change the radio display name.",
autoRejoinDescription = "Reconnect after spawning or restarting the phone resource",
radioNotifications = "Radio notifications", notificationsDescription = "Show joins and leaves on your current channel",
memberJoined = "{name} joined the radio", memberLeft = "{name} left the radio", unknownMember = "Unknown",
tabs = { radio = "Radio", settings = "Settings" },
errors = {
invalid_frequency = "Enter a valid frequency.", invalid_volume = "Enter a valid volume.",
channel_locked = "You do not have access to this channel.", secondary_locked = "You do not have access to the secondary channel.",
voice_unavailable = "The configured radio voice service is unavailable.", player_unavailable = "Your player data is not available.",
rate_limited = "Please wait before changing channel again.", invalid_setting = "This setting is invalid.",
badge_disabled = "Service numbers are disabled.", badge_forbidden = "This service number is not allowed.",
display_name_disabled = "Radio display names are disabled.",
display_name_forbidden = "Your job or grade cannot change the radio display name.",
invalid_display_name = "Enter a valid radio display name.",
request_failed = "The radio request failed.", default = "The radio request failed.",
},
},
banking = {
name = "Banking", welcome = "Welcome back", totalBalance = "Total Balance", recentPeriod = "in recent activity",
actions = "Banking actions", send = "Send",
@@ -604,7 +631,10 @@ Locales["en"] = {
focus = "Focus", focusMode = "Focus", focusDescription = "Focus silences non-critical notifications while keeping alarms and important alerts available.",
notificationSounds = "Sounds", notificationDuration = "Notification Duration", seconds = "{seconds} seconds",
ringtoneVolume = "Ringtone Volume", notificationVolume = "Notification Volume",
ringtone = "Ringtone", notificationSound = "Notification Sound", appearanceMode = "Appearance Mode",
ringtone = "Ringtone", notificationSound = "Notification Sound", graphicsMode = "Graphics Mode",
performanceMode = "Performance Mode", performanceModeDescription = "Blur-free glass simulation for better CEF performance",
ultimateMode = "Ultimate Mode", ultimateModeDescription = "Full blur and glass effects for compatible FiveM clients",
appearanceMode = "Appearance Mode",
automatic = "Automatic", light = "Light", dark = "Dark", phoneScale = "Phone Scale", phoneFrame = "Phone Frame",
screenBrightness = "Screen Brightness", rotationLock = "Rotation Lock",
about = "About", deviceName = "Device Name", deviceNameValue = "Sky Phone", softwareVersion = "Software Version",
@@ -637,7 +667,11 @@ Locales["en"] = {
rotationLocked = "Toggle Rotation Lock",
notifications = "Toggle notifications for {app}", notificationSounds = "Toggle notification sounds for {app}",
},
frames = { black = "Black", blue = "Blue", green = "Green", lavender = "Lavender", white = "White" },
frames = {
black = "Black", blue = "Blue", green = "Green", lavender = "Lavender", red = "Red", white = "White",
orange = "Orange", yellow = "Yellow", lime = "Lime", teal = "Teal", cyan = "Cyan", purple = "Purple",
pink = "Pink", gold = "Gold", rgb = "RGB",
},
ringtones = { skyline = "Skyline", horizon = "Horizon", pulse = "Pulse" },
notificationSoundsList = { chime = "Chime", signal = "Signal", soft = "Soft" },
wallpapers = { midnight = "Midnight wallpaper", aurora = "Aurora wallpaper", ember = "Ember wallpaper" },
+3
View File
@@ -27,7 +27,9 @@ client_scripts {
'source/client/animations.lua',
'source/client/camera.lua',
'source/client/garage.lua',
'source/bridge/client/radio.lua',
'source/client/main.lua',
'source/client/radio.lua',
}
server_scripts {
@@ -58,6 +60,7 @@ server_scripts {
'source/server/fliptok.lua',
'source/server/map.lua',
'source/server/calendar.lua',
'source/server/radio.lua',
}
files {
+120
View File
@@ -0,0 +1,120 @@
local provider
local provider_resources = {
yaca = "yaca-voice",
pma = "pma-voice",
saltychat = "saltychat",
}
local provider_aliases = {
["yaca-voice"] = "yaca",
["pma-voice"] = "pma",
salty = "saltychat",
}
local function resolve_provider()
if provider and GetResourceState(provider_resources[provider]) == "started" then
return provider
end
provider = nil
local configured = Config.Radio.VoiceProvider
if configured ~= "auto" then
local selected = provider_aliases[configured] or configured
if provider_resources[selected] and GetResourceState(provider_resources[selected]) == "started" then
provider = selected
return provider
end
return nil
end
local providers = {
{ name = "yaca", resource = "yaca-voice" },
{ name = "pma", resource = "pma-voice" },
{ name = "saltychat", resource = "saltychat" },
}
for _, candidate in ipairs(providers) do
if GetResourceState(candidate.resource) == "started" then
provider = candidate.name
return provider
end
end
return nil
end
function Bridge.Radio.GetProvider()
return resolve_provider()
end
function Bridge.Radio.SupportsSecondary()
local selected = resolve_provider()
return Config.Radio.AllowSecondary and (selected == "yaca" or selected == "saltychat")
end
function Bridge.Radio.Join(primary, secondary)
local selected = resolve_provider()
if selected == "yaca" then
local voice = exports["yaca-voice"]
if not voice:isRadioEnabled() then
voice:enableRadio(true)
Wait(100)
end
voice:setActiveRadioChannel(1)
voice:changeRadioFrequency(tostring(primary))
if secondary > 0 and Bridge.Radio.SupportsSecondary() then
voice:setSecondaryRadioChannel(2)
voice:changeRadioFrequencyRaw(2, tostring(secondary))
voice:muteRadioChannelRaw(2, false)
else
voice:changeRadioFrequencyRaw(2, "0")
voice:muteRadioChannelRaw(2, true)
end
voice:setActiveRadioChannel(1)
return true
end
if selected == "pma" then
exports["pma-voice"]:setRadioChannel(primary)
return true
end
if selected == "saltychat" then
exports.saltychat:SetRadioChannel(tostring(primary), true)
exports.saltychat:SetRadioChannel(secondary > 0 and tostring(secondary) or "", false)
return true
end
Bridge.Debug("error", "[sky_phone] No supported radio voice provider is running.")
return false
end
function Bridge.Radio.Leave()
local selected = resolve_provider()
if selected == "yaca" then
exports["yaca-voice"]:enableRadio(false)
elseif selected == "pma" then
exports["pma-voice"]:setRadioChannel(0)
elseif selected == "saltychat" then
exports.saltychat:SetRadioChannel("", true)
exports.saltychat:SetRadioChannel("", false)
end
end
function Bridge.Radio.SetVolume(volume)
local selected = resolve_provider()
if selected == "yaca" then
exports["yaca-voice"]:changeRadioChannelVolumeRaw(1, volume / 100)
if Bridge.Radio.SupportsSecondary() then
exports["yaca-voice"]:changeRadioChannelVolumeRaw(2, volume / 100)
end
elseif selected == "pma" then
exports["pma-voice"]:setRadioVolume(volume)
elseif selected == "saltychat" then
exports.saltychat:SetRadioVolume(volume / 100)
end
end
AddEventHandler("onResourceStop", function(resource_name)
if resource_name == GetCurrentResourceName() then
Bridge.Radio.Leave()
end
end)
@@ -90,6 +90,20 @@ function Bridge.Framework.GetBirthdate(source)
return player and (player.get("dateofbirth") or player.get("dob")) or nil
end
function Bridge.Framework.GetJob(source)
local player = get_player(source)
local job = player and player.getJob()
if not job then
return { name = "", label = "", grade = 0, gradeLabel = "" }
end
return {
name = job.name or "",
label = job.label or "",
grade = tonumber(job.grade) or 0,
gradeLabel = job.grade_label or job.label or "",
}
end
function Bridge.Framework.RegisterUsableItem(item_name, callback)
ESX.RegisterUsableItem(item_name, callback)
return true
@@ -70,6 +70,18 @@ function Bridge.Framework.GetBirthdate(source)
return character and character.birthdate or nil
end
function Bridge.Framework.GetJob(source)
local player = get_player(source)
local job = player and player.PlayerData and player.PlayerData.job
local grade = job and job.grade
return {
name = job and job.name or "",
label = job and job.label or "",
grade = type(grade) == "table" and tonumber(grade.level) or tonumber(grade) or 0,
gradeLabel = type(grade) == "table" and (grade.name or job.label) or (job and job.label or ""),
}
end
function Bridge.Framework.RegisterUsableItem(item_name, callback)
QBCore.Functions.CreateUseableItem(item_name, callback)
return true
@@ -59,6 +59,18 @@ function Bridge.Framework.GetBirthdate(source)
return character and character.birthdate or nil
end
function Bridge.Framework.GetJob(source)
local player = get_player(source)
local job = player and player.PlayerData and player.PlayerData.job
local grade = job and job.grade
return {
name = job and job.name or "",
label = job and job.label or "",
grade = type(grade) == "table" and tonumber(grade.level) or tonumber(grade) or 0,
gradeLabel = type(grade) == "table" and (grade.name or job.label) or (job and job.label or ""),
}
end
function Bridge.Framework.RegisterUsableItem(item_name, callback)
exports.qbx_core:CreateUseableItem(item_name, callback)
return true
+1
View File
@@ -3,6 +3,7 @@ Bridge.Callbacks = Bridge.Callbacks or {}
Bridge.Database = Bridge.Database or {}
Bridge.Framework = Bridge.Framework or {}
Bridge.Inventory = Bridge.Inventory or {}
Bridge.Radio = Bridge.Radio or {}
local level_colours = {
debug = "^5",
+1
View File
@@ -201,6 +201,7 @@ end
RegisterNUICallback("ui:ready", function(_, cb)
Bridge.Debug("debug", "[sky_phone] NUI reported ready.", { always = true })
TriggerEvent("sky_phone:client:nuiReady")
if open_requested and device_payload then
send_open_message()
end
+304
View File
@@ -0,0 +1,304 @@
local current_volume = math.max(0, math.min(100, tonumber(Config.Radio.DefaultVolume) or 50))
local current_primary = 0
local current_secondary = 0
local radio_settings = {
autoRejoin = Config.Radio.AutoRejoin,
notifications = Config.Radio.Notifications,
}
local auto_rejoin_pending = false
local hud_members = { [1] = {}, [2] = {} }
local hud_talking = {}
local function get_hud_config()
local hud = type(Config.Radio.Hud) == "table" and Config.Radio.Hud or {}
local position = type(hud.Position) == "table" and hud.Position or {}
return {
enabled = hud.Enabled == true,
horizontal = position.Horizontal == "left" and "left" or "right",
vertical = position.Vertical == "bottom" and "bottom" or "top",
horizontalOffset = math.max(0.0, math.min(100.0, tonumber(position.HorizontalOffset) or 2.0)),
verticalOffset = math.max(0.0, math.min(100.0, tonumber(position.VerticalOffset) or 30.0)),
speakerPersistMilliseconds = math.max(
0,
math.min(10000, math.floor(tonumber(hud.SpeakerPersistMilliseconds) or 3000))
),
}
end
local function send_hud_config()
SendNUIMessage({ type = "radio:hud-config", data = get_hud_config() })
end
local function send_hud_members()
local combined = {}
for channel_id = 1, 2 do
for player_id, member in pairs(hud_members[channel_id]) do
if not combined[player_id] then
local talking = hud_talking[player_id]
combined[player_id] = {
id = player_id,
name = member.name,
badge = member.badge,
talking = talking and talking.state or false,
channel = talking and talking.channel or channel_id,
}
end
end
end
local members = {}
for _, member in pairs(combined) do
members[#members + 1] = member
end
table.sort(members, function(left, right)
return left.name:lower() < right.name:lower()
end)
SendNUIMessage({ type = "radio:hud-update", data = { members = members } })
end
local function clear_hud_members()
hud_members = { [1] = {}, [2] = {} }
hud_talking = {}
send_hud_members()
end
local function set_hud_members(channel_id, members)
local channel_members = {}
if type(members) == "table" then
for _, member in ipairs(members) do
local player_id = tonumber(member.id)
if player_id then
channel_members[player_id] = {
name = tostring(member.name or ("ID " .. player_id)),
badge = tostring(member.badge or ""),
}
end
end
end
hud_members[channel_id] = channel_members
send_hud_members()
end
local function set_hud_talking(player_id, state, channel_id)
player_id = tonumber(player_id)
if not player_id then
return
end
hud_talking[player_id] = state and { state = true, channel = channel_id == 2 and 2 or 1 } or nil
send_hud_members()
end
local function request(name, data)
local result = Bridge.Callbacks.Trigger("sky_phone:radio:" .. name, data or {})
if type(result) ~= "table" then
return { success = false, error = "request_failed" }
end
return result
end
local function apply_server_state(data)
if type(data) ~= "table" then
return
end
current_primary = tonumber(data.frequency) or current_primary
current_secondary = tonumber(data.secondaryFrequency) or current_secondary
if type(data.settings) == "table" then
radio_settings.autoRejoin = data.settings.autoRejoin == true
radio_settings.notifications = data.settings.notifications == true
end
end
local function join_radio(primary, secondary)
if not Bridge.Radio.SupportsSecondary() then
secondary = 0
end
local approved = request("connect", {
frequency = primary,
secondaryFrequency = secondary,
})
if not approved.success then
return approved
end
local data = approved.data or {}
local approved_primary = tonumber(data.frequency) or 0
local approved_secondary = tonumber(data.secondaryFrequency) or 0
if not Bridge.Radio.Join(approved_primary, approved_secondary) then
request("disconnect")
return { success = false, error = "voice_unavailable" }
end
if current_primary ~= approved_primary or current_secondary ~= approved_secondary then
clear_hud_members()
end
current_primary = approved_primary
current_secondary = approved_secondary
Bridge.Radio.SetVolume(current_volume)
data.secondaryFrequency = approved_secondary
data.provider = Bridge.Radio.GetProvider()
data.secondarySupported = Bridge.Radio.SupportsSecondary()
return { success = true, data = data }
end
local function leave_radio()
Bridge.Radio.Leave()
current_primary = 0
current_secondary = 0
clear_hud_members()
return request("disconnect")
end
RegisterNUICallback("radio:get", function(_, cb)
local result = request("get")
if result.success then
apply_server_state(result.data)
result.data.volume = current_volume
result.data.provider = Bridge.Radio.GetProvider()
result.data.secondarySupported = Bridge.Radio.SupportsSecondary()
end
cb(result)
end)
RegisterNUICallback("radio:connect", function(data, cb)
cb(join_radio(data.frequency, data.secondaryFrequency))
end)
RegisterNUICallback("radio:disconnect", function(_, cb)
cb(leave_radio())
end)
RegisterNUICallback("radio:set-volume", function(data, cb)
local volume = tonumber(data.volume)
if not volume then
cb({ success = false, error = "invalid_volume" })
return
end
current_volume = math.max(0, math.min(100, math.floor(volume + 0.5)))
Bridge.Radio.SetVolume(current_volume)
cb({ success = true, data = { volume = current_volume } })
end)
RegisterNUICallback("radio:save-settings", function(data, cb)
local result = request("save-settings", data)
if result.success then
apply_server_state({ settings = result.data })
end
cb(result)
end)
RegisterNUICallback("radio:save-badge", function(data, cb)
cb(request("save-badge", data))
end)
RegisterNUICallback("radio:save-display-name", function(data, cb)
cb(request("save-display-name", data))
end)
RegisterNetEvent("sky_phone:radio:members", function(data)
local frequency = tonumber(data.frequency)
local channel_id = frequency == current_primary and 1 or frequency == current_secondary and 2 or nil
if not channel_id then
return
end
set_hud_members(channel_id, data.members)
if channel_id == 1 then
SendNUIMessage({ type = "radio:updated", data = data })
end
end)
RegisterNetEvent("yaca:external:isRadioReceiving", function(state, channel, player_id)
if Bridge.Radio.GetProvider() ~= "yaca" then
return
end
set_hud_talking(player_id, state == true, tonumber(channel) or 1)
end)
RegisterNetEvent("yaca:external:isRadioTalking", function(state, channel)
if Bridge.Radio.GetProvider() ~= "yaca" then
return
end
set_hud_talking(GetPlayerServerId(PlayerId()), state == true, tonumber(channel) or 1)
end)
RegisterNetEvent("yaca:external:isRadioEnabled", function(state)
if Bridge.Radio.GetProvider() == "yaca" and not state then
clear_hud_members()
end
end)
AddEventHandler("sky_phone:client:nuiReady", function()
send_hud_config()
send_hud_members()
end)
RegisterNetEvent("sky_phone:radio:notification", function(data)
if not radio_settings.notifications or current_primary <= 0 then
return
end
local locale = (Locales[Config.Bridge.Locale] or Locales.en).Nui.Apps.radio
local template = data.joined and locale.memberJoined or locale.memberLeft
SendNUIMessage({
type = "notification:show",
data = {
appId = "radio",
title = locale.name,
text = template:gsub("{name}", tostring(data.playerName or locale.unknownMember)),
},
})
end)
local function try_auto_rejoin()
if auto_rejoin_pending or current_primary > 0 then
return
end
auto_rejoin_pending = true
local result = request("get")
if result.success then
apply_server_state(result.data)
local data = result.data or {}
if radio_settings.autoRejoin and tonumber(data.savedFrequency) and tonumber(data.savedFrequency) > 0 then
local joined = join_radio(data.savedFrequency, data.savedSecondaryFrequency)
if not joined.success then
Bridge.Debug("warn", "[sky_phone] Radio auto-rejoin failed: %s", tostring(joined.error))
end
end
end
auto_rejoin_pending = false
end
AddEventHandler("playerSpawned", function()
SetTimeout(2000, try_auto_rejoin)
end)
AddEventHandler("onResourceStart", function(resource_name)
if resource_name == GetCurrentResourceName() then
SetTimeout(5000, try_auto_rejoin)
end
end)
RegisterNetEvent("yaca:external:setRadioFrequency", function(channel, frequency)
if Bridge.Radio.GetProvider() ~= "yaca" then
return
end
local channel_id = tonumber(channel)
local value = math.max(0, tonumber(frequency) or 0)
if channel_id == 1 then
hud_members[1] = {}
current_primary = value
if value == 0 then
current_secondary = 0
hud_members[2] = {}
hud_talking = {}
request("disconnect")
else
request("connect", { frequency = current_primary, secondaryFrequency = current_secondary })
end
elseif channel_id == 2 then
hud_members[2] = {}
current_secondary = value == current_primary and 0 or value
if current_primary > 0 then
request("connect", { frequency = current_primary, secondaryFrequency = current_secondary })
end
end
send_hud_members()
end)
+18
View File
@@ -735,6 +735,24 @@ local schema = {
},
tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
},
{
name = "sky_phone_radio_profiles",
columns = {
{ name = "identifier", type = "VARCHAR(80) NOT NULL" },
{ name = "history", type = "LONGTEXT NOT NULL" },
{ name = "settings", type = "LONGTEXT NOT NULL" },
{ name = "primary_frequency", type = "DOUBLE NOT NULL DEFAULT 0" },
{ name = "secondary_frequency", type = "DOUBLE NOT NULL DEFAULT 0" },
{ name = "badge", type = "VARCHAR(32) NOT NULL DEFAULT ''" },
{ name = "display_name", type = "VARCHAR(64) NOT NULL DEFAULT ''" },
{
name = "updated_at",
type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP",
},
},
primaryKey = "identifier",
tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
},
{
name = "sky_phone_darkchat_profiles",
columns = {
+477
View File
@@ -0,0 +1,477 @@
local profiles = {}
local channels = {}
local joined_at = {}
local last_requests = {}
local function supports_secondary()
if not Config.Radio.AllowSecondary then
return false
end
local configured = Config.Radio.VoiceProvider
if configured == "pma" or configured == "pma-voice" then
return false
end
if configured ~= "auto" then
return true
end
if GetResourceState("yaca-voice") == "started" then
return true
end
if GetResourceState("pma-voice") == "started" then
return false
end
return GetResourceState("saltychat") == "started"
end
local function default_profile()
return {
history = {},
settings = {
autoRejoin = Config.Radio.AutoRejoin,
notifications = Config.Radio.Notifications,
},
primaryFrequency = 0,
secondaryFrequency = 0,
badge = "",
displayName = "",
}
end
local function decode_table(value, fallback)
if type(value) ~= "string" or value == "" then
return fallback
end
local success, decoded = pcall(json.decode, value)
return success and type(decoded) == "table" and decoded or fallback
end
local function normalize_frequency(value, allow_zero)
local frequency = tonumber(value)
if not frequency or frequency ~= frequency then
return nil
end
if allow_zero and frequency == 0 then
return 0
end
if frequency < Config.Radio.FrequencyMin or frequency > Config.Radio.FrequencyMax then
return nil
end
local factor = 10 ^ Config.Radio.FrequencyDecimals
return math.floor(frequency * factor + 0.5) / factor
end
local function can_set_display_name(source)
local config = Config.Radio.DisplayName
if type(config) ~= "table" or not config.Enabled then
return false
end
local job = Bridge.Framework.GetJob(source)
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
end
local function normalize_display_name(value)
local name = tostring(value or ""):gsub("%c", ""):gsub("%s+", " ")
name = name:match("^%s*(.-)%s*$") or ""
local length = utf8.len(name)
if not length then
return nil
end
local maximum = math.max(1, math.min(tonumber(Config.Radio.DisplayName.MaxLength) or 32, 64))
if length > maximum then
local next_character = utf8.offset(name, maximum + 1)
name = next_character and name:sub(1, next_character - 1) or name
end
return name
end
local function has_channel_access(source, frequency)
local job = Bridge.Framework.GetJob(source)
for _, locked in ipairs(Config.Radio.LockedChannels or {}) do
local minimum = tonumber(locked.range and locked.range[1])
local maximum = tonumber(locked.range and locked.range[2])
if minimum and maximum and frequency >= minimum and frequency <= maximum then
return locked.jobs and locked.jobs[job.name] == true
end
end
return true
end
local function sanitize_history(source, history)
local result = {}
local seen = {}
if type(history) ~= "table" then
return result
end
for index = 1, #history do
local entry = history[index]
if type(entry) == "table" then
local primary = normalize_frequency(entry.primary or entry.frequency, false)
local secondary = normalize_frequency(entry.secondary or entry.secondaryFrequency or 0, true)
if primary and secondary and secondary == primary then
secondary = 0
end
if primary and secondary and has_channel_access(source, primary)
and (secondary == 0 or has_channel_access(source, secondary)) then
local key = ("%.3f|%.3f"):format(primary, secondary)
if not seen[key] then
result[#result + 1] = { primary = primary, secondary = secondary }
seen[key] = true
end
end
end
if #result >= Config.Radio.HistoryLimit then
break
end
end
return result
end
local function load_profile(source)
local identifier = Bridge.Framework.GetIdentifier(source)
if not identifier then
return nil, nil
end
if profiles[identifier] then
return identifier, profiles[identifier]
end
local profile = default_profile()
local rows = Bridge.Database.Query([[
SELECT `history`, `settings`, `primary_frequency`, `secondary_frequency`, `badge`, `display_name`
FROM `sky_phone_radio_profiles` WHERE `identifier` = ? LIMIT 1
]], { identifier })
local row = rows[1]
if row then
profile.history = sanitize_history(source, decode_table(row.history, {}))
local settings = decode_table(row.settings, {})
profile.settings.autoRejoin = settings.autoRejoin == true
profile.settings.notifications = settings.notifications == true
profile.primaryFrequency = normalize_frequency(row.primary_frequency, true) or 0
profile.secondaryFrequency = normalize_frequency(row.secondary_frequency, true) or 0
profile.badge = tostring(row.badge or ""):sub(1, math.min(Config.Radio.Badge.MaxLength, 32))
profile.displayName = normalize_display_name(row.display_name) or ""
end
profiles[identifier] = profile
return identifier, profile
end
local function save_profile(identifier, profile)
Bridge.Database.Query([[
INSERT INTO `sky_phone_radio_profiles`
(`identifier`, `history`, `settings`, `primary_frequency`, `secondary_frequency`, `badge`, `display_name`)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE `history` = VALUES(`history`), `settings` = VALUES(`settings`),
`primary_frequency` = VALUES(`primary_frequency`),
`secondary_frequency` = VALUES(`secondary_frequency`), `badge` = VALUES(`badge`),
`display_name` = VALUES(`display_name`)
]], {
identifier,
json.encode(profile.history),
json.encode(profile.settings),
profile.primaryFrequency,
profile.secondaryFrequency,
profile.badge,
profile.displayName,
})
end
local function get_effective_display_name(source, profile)
if not can_set_display_name(source) then
return ""
end
if not profile then
local _, loaded_profile = load_profile(source)
profile = loaded_profile
end
return profile and profile.displayName or ""
end
local function get_radio_member_name(source)
local display_name = get_effective_display_name(source)
return display_name ~= "" and display_name or GetPlayerName(source) or "Unknown"
end
local function frequency_set(channel)
local result = {}
if channel and channel.primary and channel.primary > 0 then
result[channel.primary] = true
end
if channel and channel.secondary and channel.secondary > 0 then
result[channel.secondary] = true
end
return result
end
local function get_members(frequency)
local members = {}
for player_source, channel in pairs(channels) do
if channel.primary == frequency or channel.secondary == frequency then
local job = Bridge.Framework.GetJob(player_source)
local _, profile = load_profile(player_source)
members[#members + 1] = {
id = player_source,
name = get_radio_member_name(player_source),
badge = Config.Radio.Badge.Enabled and profile and profile.badge or "",
joinTime = os.time() - (joined_at[player_source] or os.time()),
rank = job.gradeLabel,
rankNumber = job.grade,
}
end
end
table.sort(members, function(left, right)
return left.name:lower() < right.name:lower()
end)
return members
end
local function broadcast_frequency(frequency)
if not frequency or frequency <= 0 then
return
end
local members = get_members(frequency)
for _, member in ipairs(members) do
TriggerClientEvent("sky_phone:radio:members", member.id, {
frequency = frequency,
members = members,
})
end
end
local function notify_frequency(frequency, excluded_source, player_name, joined)
if not frequency or frequency <= 0 then
return
end
for player_source, channel in pairs(channels) do
if player_source ~= excluded_source
and (channel.primary == frequency or channel.secondary == frequency) then
TriggerClientEvent("sky_phone:radio:notification", player_source, {
joined = joined,
playerName = player_name,
})
end
end
end
local function remove_from_channels(source)
local previous = channels[source]
if not previous then
return
end
channels[source] = nil
joined_at[source] = nil
local name = get_radio_member_name(source)
for frequency in pairs(frequency_set(previous)) do
notify_frequency(frequency, source, name, false)
broadcast_frequency(frequency)
end
end
local function rate_limited(source, action, milliseconds)
local now = GetGameTimer()
local key = ("%s:%s"):format(source, action)
if last_requests[key] and now - last_requests[key] < milliseconds then
return true
end
last_requests[key] = now
return false
end
Bridge.Callbacks.Register("sky_phone:radio:get", function(source)
local _, profile = load_profile(source)
if not profile then
return { success = false, error = "player_unavailable" }
end
local channel = channels[source]
return {
success = true,
data = {
connected = channel ~= nil,
frequency = channel and channel.primary or 0,
secondaryFrequency = channel and channel.secondary or 0,
members = channel and get_members(channel.primary) or {},
history = profile.history,
settings = profile.settings,
badge = profile.badge,
badgeEnabled = Config.Radio.Badge.Enabled,
badgeMaxLength = math.min(Config.Radio.Badge.MaxLength, 32),
displayName = profile.displayName,
displayNameAllowed = can_set_display_name(source),
displayNameEnabled = Config.Radio.DisplayName.Enabled,
displayNameMaxLength = math.min(Config.Radio.DisplayName.MaxLength, 64),
frequencyMin = Config.Radio.FrequencyMin,
frequencyMax = Config.Radio.FrequencyMax,
frequencyStep = 1 / (10 ^ Config.Radio.FrequencyDecimals),
savedFrequency = profile.primaryFrequency,
savedSecondaryFrequency = profile.secondaryFrequency,
},
}
end)
Bridge.Callbacks.Register("sky_phone:radio:connect", function(source, data)
if rate_limited(source, "connect", 500) then
return { success = false, error = "rate_limited" }
end
local primary = normalize_frequency(data.frequency, false)
local secondary = normalize_frequency(data.secondaryFrequency or 0, true)
if not primary or not secondary then
return { success = false, error = "invalid_frequency" }
end
if secondary == primary then
secondary = 0
end
if not supports_secondary() then
secondary = 0
end
if not has_channel_access(source, primary) then
return { success = false, error = "channel_locked" }
end
if secondary > 0 and not has_channel_access(source, secondary) then
return { success = false, error = "secondary_locked" }
end
local identifier, profile = load_profile(source)
if not profile then
return { success = false, error = "player_unavailable" }
end
local previous = channels[source]
local previous_set = frequency_set(previous)
channels[source] = { primary = primary, secondary = secondary }
joined_at[source] = os.time()
profile.primaryFrequency = primary
profile.secondaryFrequency = secondary
profile.history = sanitize_history(source, (function()
local history = { { primary = primary, secondary = secondary } }
for _, entry in ipairs(profile.history) do
history[#history + 1] = entry
end
return history
end)())
save_profile(identifier, profile)
local current_set = frequency_set(channels[source])
local name = get_radio_member_name(source)
for frequency in pairs(previous_set) do
if not current_set[frequency] then
notify_frequency(frequency, source, name, false)
broadcast_frequency(frequency)
end
end
for frequency in pairs(current_set) do
if not previous_set[frequency] then
notify_frequency(frequency, source, name, true)
end
broadcast_frequency(frequency)
end
return {
success = true,
data = {
connected = true,
frequency = primary,
secondaryFrequency = secondary,
members = get_members(primary),
history = profile.history,
},
}
end)
Bridge.Callbacks.Register("sky_phone:radio:disconnect", function(source)
remove_from_channels(source)
local identifier, profile = load_profile(source)
if profile then
profile.primaryFrequency = 0
profile.secondaryFrequency = 0
save_profile(identifier, profile)
end
return { success = true }
end)
Bridge.Callbacks.Register("sky_phone:radio:save-settings", function(source, data)
local key = tostring(data.key or "")
if key ~= "autoRejoin" and key ~= "notifications" then
return { success = false, error = "invalid_setting" }
end
local identifier, profile = load_profile(source)
if not profile then
return { success = false, error = "player_unavailable" }
end
profile.settings[key] = data.value == true
save_profile(identifier, profile)
return { success = true, data = profile.settings }
end)
local function badge_forbidden(badge)
local digits = badge:gsub("%D", "")
for _, pattern in ipairs(Config.Radio.Badge.ForbiddenPatterns or {}) do
if digits:find(tostring(pattern), 1, true) then
return true
end
end
return false
end
Bridge.Callbacks.Register("sky_phone:radio:save-badge", function(source, data)
if not Config.Radio.Badge.Enabled then
return { success = false, error = "badge_disabled" }
end
local badge = tostring(data.badge or ""):gsub("[^%w_%-]", ""):sub(1, math.min(Config.Radio.Badge.MaxLength, 32))
if badge_forbidden(badge) then
return { success = false, error = "badge_forbidden" }
end
local identifier, profile = load_profile(source)
if not profile then
return { success = false, error = "player_unavailable" }
end
profile.badge = badge
save_profile(identifier, profile)
for frequency in pairs(frequency_set(channels[source])) do
broadcast_frequency(frequency)
end
return { success = true, data = { badge = badge } }
end)
Bridge.Callbacks.Register("sky_phone:radio:save-display-name", function(source, data)
if not Config.Radio.DisplayName.Enabled then
return { success = false, error = "display_name_disabled" }
end
if rate_limited(source, "save-display-name", 750) then
return { success = false, error = "rate_limited" }
end
if not can_set_display_name(source) then
return { success = false, error = "display_name_forbidden" }
end
local display_name = normalize_display_name(data.displayName)
if display_name == nil then
return { success = false, error = "invalid_display_name" }
end
local identifier, profile = load_profile(source)
if not profile then
return { success = false, error = "player_unavailable" }
end
profile.displayName = display_name
save_profile(identifier, profile)
for frequency in pairs(frequency_set(channels[source])) do
broadcast_frequency(frequency)
end
return { success = true, data = { displayName = display_name } }
end)
AddEventHandler("playerDropped", function()
local player_source = source
local identifier = Bridge.Framework.GetIdentifier(player_source)
remove_from_channels(player_source)
if identifier then
profiles[identifier] = nil
end
for key in pairs(last_requests) do
if key:sub(1, #tostring(player_source) + 1) == tostring(player_source) .. ":" then
last_requests[key] = nil
end
end
end)
+12
View File
@@ -241,6 +241,18 @@ CREATE TABLE IF NOT EXISTS `sky_phone_calendar_events` (
FOREIGN KEY (`account_id`) REFERENCES `sky_phone_accounts` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `sky_phone_radio_profiles` (
`identifier` VARCHAR(80) NOT NULL,
`history` LONGTEXT NOT NULL,
`settings` LONGTEXT NOT NULL,
`primary_frequency` DOUBLE NOT NULL DEFAULT 0,
`secondary_frequency` DOUBLE NOT NULL DEFAULT 0,
`badge` VARCHAR(32) NOT NULL DEFAULT '',
`display_name` VARCHAR(64) NOT NULL DEFAULT '',
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`identifier`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `sky_phone_fliptok_profiles` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, `account_id` BIGINT UNSIGNED NOT NULL,
`handle` VARCHAR(24) CHARACTER SET ascii COLLATE ascii_general_ci NOT NULL, `display_name` VARCHAR(40) NOT NULL,