Merge branch 'feature/funk' into dev
@@ -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.
|
||||
|
||||
@@ -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 |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 16 KiB |
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)',
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
@@ -14,6 +14,7 @@ export type PhoneAppId =
|
||||
| 'mail'
|
||||
| 'map'
|
||||
| 'notes'
|
||||
| 'radio'
|
||||
| 'photos'
|
||||
| 'app-store'
|
||||
| 'settings'
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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%);
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
@@ -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)"
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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]',
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
|
||||