reforged ui

This commit is contained in:
Dominik
2026-08-10 02:28:33 +02:00
parent 7a16903b00
commit 8f0d5ac4a0
9 changed files with 1343 additions and 34 deletions
Binary file not shown.
+2
View File
@@ -18,6 +18,7 @@ import PhonePasscode from '@/components/PhonePasscode.vue'
import PhoneNotifications from '@/components/PhoneNotifications.vue'
import NotificationPhonePreview from '@/components/NotificationPhonePreview.vue'
import PhoneStatusBar from '@/components/PhoneStatusBar.vue'
import PayphoneOverlay from '@/components/PayphoneOverlay.vue'
import RadioHud from '@/components/RadioHud.vue'
import SimPhonePicker, {
type SimPhoneChoice,
@@ -804,6 +805,7 @@ onBeforeUnmount(() => {
<template>
<PhoneMediaCapture />
<RadioHud />
<PayphoneOverlay />
<SimPhonePicker
v-if="simPicker"
:choices="simPicker.choices"
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 MiB

+716
View File
@@ -0,0 +1,716 @@
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
import payphoneFrame from '@/assets/img/payphone/american-payphone-frame.png'
import { nuiCall } from '@/utils/nui'
type PayphoneState =
| 'idle'
| 'dialing'
| 'ringing'
| 'connected'
| 'completed'
| 'cancelled'
| 'declined'
| 'busy'
| 'unavailable'
| 'no_answer'
| 'disconnected'
| 'insufficient_funds'
type PayphoneLocales = Record<string, string>
type PayphoneCall = {
answeredAt?: number
elapsedSeconds?: number
id: string
otherNumber: string
state: PayphoneState
totalCost?: number
}
type PayphoneOpenPayload = {
currency: string
locales: PayphoneLocales
maxNumberLength: number
pricePerSecond: number
}
const keypad = [
{ digit: '1', letters: '' },
{ digit: '2', letters: 'ABC' },
{ digit: '3', letters: 'DEF' },
{ digit: '4', letters: 'GHI' },
{ digit: '5', letters: 'JKL' },
{ digit: '6', letters: 'MNO' },
{ digit: '7', letters: 'PQRS' },
{ digit: '8', letters: 'TUV' },
{ digit: '9', letters: 'WXYZ' },
{ digit: '*', letters: '' },
{ digit: '0', letters: '+' },
{ digit: '#', letters: '' },
]
const visible = ref(false)
const number = ref('')
const state = ref<PayphoneState>('idle')
const call = ref<PayphoneCall | null>(null)
const locales = ref<PayphoneLocales>({})
const currency = ref('$')
const pricePerSecond = ref(0)
const maxNumberLength = ref(10)
const input = ref<HTMLInputElement | null>(null)
const now = ref(Date.now())
const statusOverride = ref('')
let ticker: number | undefined
let buttonSoundIndex = 0
const buttonSounds: HTMLAudioElement[] = []
function prepareButtonSounds(): void {
if (buttonSounds.length) return
for (let index = 0; index < 4; index += 1) {
const sound = new Audio(`${import.meta.env.BASE_URL}sounds/button.mp3`)
sound.preload = 'auto'
sound.volume = 0.55
buttonSounds.push(sound)
}
}
function playButtonSound(): void {
prepareButtonSounds()
const sound = buttonSounds[buttonSoundIndex]
buttonSoundIndex = (buttonSoundIndex + 1) % buttonSounds.length
sound.currentTime = 0
const playback = sound.play()
if (playback) void playback.catch(() => undefined)
}
const active = computed(() =>
['dialing', 'ringing', 'connected'].includes(state.value),
)
const elapsedSeconds = computed(() => {
const serverElapsed = call.value?.elapsedSeconds ?? 0
if (state.value !== 'connected' || !call.value?.answeredAt)
return serverElapsed
return Math.max(
serverElapsed,
Math.floor(now.value / 1000 - call.value.answeredAt),
)
})
const totalCost = computed(() => elapsedSeconds.value * pricePerSecond.value)
const rateText = computed(() =>
text('rate')
.replace('{currency}', currency.value)
.replace('{price}', String(pricePerSecond.value)),
)
const statusText = computed(() => {
if (statusOverride.value) return statusOverride.value
const key: Record<PayphoneState, string> = {
busy: 'busy',
cancelled: 'callEnded',
completed: 'callEnded',
connected: 'connected',
declined: 'declined',
dialing: 'dialing',
disconnected: 'disconnected',
idle: 'ready',
insufficient_funds: 'insufficientFunds',
no_answer: 'noAnswer',
ringing: 'ringing',
unavailable: 'unavailable',
}
return text(key[state.value])
})
function text(key: string): string {
return locales.value[key] ?? ''
}
function formatDuration(seconds: number): string {
const minutes = Math.floor(seconds / 60)
return `${String(minutes).padStart(2, '0')}:${String(seconds % 60).padStart(2, '0')}`
}
function appendDigit(digit: string): void {
if (active.value || !/^\d$/.test(digit)) return
playButtonSound()
statusOverride.value = ''
state.value = 'idle'
call.value = null
number.value = `${number.value}${digit}`.slice(0, maxNumberLength.value)
input.value?.focus()
}
function deleteDigit(): void {
if (active.value || !number.value) return
playButtonSound()
statusOverride.value = ''
state.value = 'idle'
call.value = null
number.value = number.value.slice(0, -1)
input.value?.focus()
}
function clearNumber(): void {
if (active.value || !number.value) return
playButtonSound()
statusOverride.value = ''
state.value = 'idle'
call.value = null
number.value = ''
input.value?.focus()
}
function sanitizeNumber(): void {
statusOverride.value = ''
state.value = 'idle'
call.value = null
number.value = number.value.replace(/\D/g, '').slice(0, maxNumberLength.value)
}
function applyCall(nextCall: PayphoneCall): void {
statusOverride.value = ''
call.value = nextCall
state.value = nextCall.state
if (nextCall.otherNumber) number.value = nextCall.otherNumber
}
async function dial(): Promise<void> {
if (active.value || !number.value) return
playButtonSound()
state.value = 'dialing'
const response = await nuiCall<PayphoneCall>('payphone:dial', {
phoneNumber: number.value,
})
if (response.success && response.data) {
applyCall(response.data)
return
}
call.value = null
const errorStates: Record<string, PayphoneState> = {
busy: 'busy',
insufficient_funds: 'insufficient_funds',
invalid_number: 'idle',
voice_unavailable: 'disconnected',
}
state.value = errorStates[response.error ?? ''] ?? 'disconnected'
if (response.error === 'invalid_number')
statusOverride.value = text('invalidNumber')
else if (response.error === 'voice_unavailable')
statusOverride.value = text('voiceUnavailable')
else if (!errorStates[response.error ?? ''])
statusOverride.value = text('requestFailed')
}
async function hangup(): Promise<void> {
if (!call.value || !['ringing', 'connected'].includes(state.value)) return
playButtonSound()
await nuiCall('payphone:hangup')
}
async function close(): Promise<void> {
playButtonSound()
await nuiCall('payphone:close')
}
function onMessage(event: MessageEvent): void {
if (event.data?.type === 'payphone:open' && event.data.data) {
const payload = event.data.data as PayphoneOpenPayload
currency.value = payload.currency
locales.value = { ...payload.locales }
maxNumberLength.value = payload.maxNumberLength
pricePerSecond.value = payload.pricePerSecond
number.value = ''
call.value = null
state.value = 'idle'
statusOverride.value = ''
visible.value = true
void nextTick(() => input.value?.focus())
} else if (event.data?.type === 'payphone:state' && event.data.data) {
applyCall(event.data.data as PayphoneCall)
} else if (event.data?.type === 'payphone:close') {
visible.value = false
}
}
function onKeydown(event: KeyboardEvent): void {
if (!visible.value) return
if (event.key === 'Escape') {
event.preventDefault()
void close()
return
}
if (event.key === 'Enter') {
event.preventDefault()
if (active.value) void hangup()
else void dial()
return
}
if (event.target === input.value) return
if (/^\d$/.test(event.key)) appendDigit(event.key)
else if (event.key === 'Backspace') deleteDigit()
}
onMounted(() => {
prepareButtonSounds()
window.addEventListener('message', onMessage)
window.addEventListener('keydown', onKeydown)
ticker = window.setInterval(() => {
now.value = Date.now()
}, 250)
})
onBeforeUnmount(() => {
window.removeEventListener('message', onMessage)
window.removeEventListener('keydown', onKeydown)
if (ticker !== undefined) window.clearInterval(ticker)
for (const sound of buttonSounds) {
sound.pause()
sound.src = ''
}
buttonSounds.length = 0
})
</script>
<template>
<Transition name="payphone-fade">
<div v-if="visible" class="payphone-overlay">
<section class="payphone-console" :aria-label="text('title')">
<img
class="payphone-console__frame"
:src="payphoneFrame"
alt=""
aria-hidden="true"
draggable="false"
/>
<button
type="button"
class="payphone-console__close"
:aria-label="text('close')"
@click="close"
>
×
</button>
<div class="payphone-display">
<header class="payphone-console__header">
<span class="payphone-console__signal" aria-hidden="true"></span>
<div>
<strong>{{ text('title') }}</strong>
<small>{{ text('subtitle') }}</small>
</div>
</header>
<div class="payphone-display__topline">
<span>{{ statusText }}</span>
<b>{{ rateText }}</b>
</div>
<label for="payphone-number">{{ text('numberLabel') }}</label>
<div class="payphone-number-row">
<input
id="payphone-number"
ref="input"
v-model="number"
:aria-label="text('numberLabel')"
:disabled="active"
:maxlength="maxNumberLength"
:placeholder="text('numberPlaceholder')"
autocomplete="off"
inputmode="numeric"
type="text"
@input="sanitizeNumber"
/>
<button
type="button"
:aria-label="text('delete')"
:disabled="active || !number"
@click="deleteDigit"
>
</button>
</div>
<div v-if="state === 'connected'" class="payphone-meter">
<span
><small>{{ text('elapsed') }}</small
>{{ formatDuration(elapsedSeconds) }}</span
>
<span
><small>{{ text('cost') }}</small
>{{ currency }}{{ totalCost }}</span
>
</div>
</div>
<div class="payphone-keypad" :aria-label="text('keypad')">
<button
v-for="key in keypad"
:key="key.digit"
type="button"
:disabled="active || !/^\d$/.test(key.digit)"
@click="appendDigit(key.digit)"
>
<strong>{{ key.digit }}</strong>
<small>{{ key.letters }}</small>
</button>
</div>
<footer class="payphone-actions">
<button
v-if="state === 'ringing' || state === 'connected'"
type="button"
class="payphone-action payphone-action--hangup"
@click="hangup"
>
<span aria-hidden="true"></span>{{ text('hangup') }}
</button>
<button
v-else
type="button"
class="payphone-action payphone-action--call"
:disabled="state === 'dialing' || !number"
@click="dial"
>
<span aria-hidden="true"></span>{{ text('call') }}
</button>
<button
type="button"
class="payphone-action payphone-action--clear"
:disabled="active || !number"
@click="clearNumber"
>
{{ text('clear') }}
</button>
</footer>
</section>
</div>
</Transition>
</template>
<style scoped>
.payphone-overlay {
position: fixed;
z-index: 10000;
inset: 0;
display: grid;
place-items: center;
background: radial-gradient(
circle at 50% 44%,
rgb(30 38 42 / 35%),
rgb(0 0 0 / 84%) 72%
);
font-family: 'Segoe UI', Arial, sans-serif;
user-select: none;
}
.payphone-console {
position: relative;
width: min(640px, 62vh, 94vw);
aspect-ratio: 2 / 3;
overflow: hidden;
filter: drop-shadow(0 34px 44px rgb(0 0 0 / 68%));
color: #e8edf0;
}
.payphone-console__frame {
position: absolute;
z-index: 0;
inset: -0.8%;
width: 101.6%;
height: 101.6%;
object-fit: contain;
pointer-events: none;
}
.payphone-console__header {
display: grid;
grid-template-columns: 8px 1fr;
align-items: center;
gap: 7px;
margin-bottom: 4%;
color: #9fbcaa;
font-family: 'Courier New', monospace;
pointer-events: none;
}
.payphone-console__header strong,
.payphone-console__header small {
display: block;
}
.payphone-console__header strong {
overflow: hidden;
font-size: clamp(8px, 1.15vw, 12px);
letter-spacing: 0.11em;
text-overflow: ellipsis;
white-space: nowrap;
}
.payphone-console__header small {
color: rgb(159 188 170 / 65%);
font-size: clamp(6px, 0.78vw, 8px);
letter-spacing: 0.14em;
}
.payphone-number-row button {
border: 0;
color: #cbd4d8;
background: transparent;
cursor: pointer;
}
.payphone-console__close {
position: absolute;
z-index: 5;
top: 4.1%;
right: 4.4%;
display: grid;
width: clamp(25px, 4.7vw, 34px);
aspect-ratio: 1;
padding: 0;
place-items: center;
border: 1px solid rgb(255 255 255 / 22%);
border-radius: 50%;
background: linear-gradient(#41494d, #161c1f);
box-shadow:
0 2px 4px rgb(0 0 0 / 70%),
inset 0 1px 0 rgb(255 255 255 / 18%);
color: #d8dddf;
font-size: clamp(18px, 3vw, 25px);
line-height: 1;
cursor: pointer;
}
.payphone-console__close:hover {
filter: brightness(1.25);
}
.payphone-console__signal {
width: 6px;
height: 6px;
border-radius: 50%;
background: #62dd91;
box-shadow: 0 0 8px #62dd91;
}
.payphone-display {
position: absolute;
z-index: 2;
top: 22.9%;
left: 34.4%;
width: 54.5%;
height: 16.5%;
padding: 3.4%;
overflow: hidden;
border-radius: 2%;
background:
linear-gradient(155deg, rgb(132 162 143 / 92%), rgb(70 99 81 / 94%)),
#75927f;
box-shadow: inset 0 0 18px rgb(5 18 10 / 48%);
color: #10261a;
font-family: 'Courier New', monospace;
}
.payphone-display__topline,
.payphone-meter {
display: flex;
justify-content: space-between;
gap: 16px;
}
.payphone-display__topline {
font-size: clamp(7px, 1vw, 10px);
font-weight: 800;
letter-spacing: 0.08em;
}
.payphone-display label {
display: block;
margin-top: 4%;
font-size: clamp(6px, 0.82vw, 8px);
font-weight: 700;
letter-spacing: 0.1em;
}
.payphone-number-row {
display: grid;
grid-template-columns: 1fr 38px;
align-items: center;
}
.payphone-number-row input {
width: 100%;
padding: 1.8% 0 0;
border: 0;
outline: 0;
color: #10261a;
background: transparent;
caret-color: #10261a;
font:
700 clamp(15px, 2.7vw, 25px) / 1.05 'Courier New',
monospace;
letter-spacing: 0.055em;
}
.payphone-number-row input::placeholder {
color: rgb(27 48 36 / 45%);
font-size: clamp(9px, 1.5vw, 14px);
letter-spacing: 0;
}
.payphone-number-row button {
color: #1b3024;
font-size: clamp(14px, 2.2vw, 20px);
}
.payphone-meter {
margin-top: 2%;
padding-top: 2%;
border-top: 1px solid rgb(27 48 36 / 24%);
}
.payphone-meter span {
font-size: clamp(9px, 1.45vw, 14px);
font-weight: 800;
}
.payphone-meter small {
margin-right: 5px;
font-size: clamp(6px, 0.75vw, 8px);
letter-spacing: 0.08em;
}
.payphone-keypad {
position: absolute;
z-index: 2;
top: 43.8%;
left: 35.2%;
width: 53.4%;
height: 30.7%;
display: grid;
grid-template-columns: repeat(3, 1fr);
grid-template-rows: repeat(4, 1fr);
gap: 2.8% 3.2%;
padding: 1.4%;
}
.payphone-keypad button {
min-width: 0;
min-height: 0;
padding: 0;
border: 1px solid #080c0e;
border-radius: 8%;
background:
linear-gradient(145deg, rgb(255 255 255 / 26%), transparent 32%),
linear-gradient(#d7ddde, #899499);
box-shadow:
0 3px 0 #070b0d,
inset 0 1px 0 #fff;
color: #172126;
cursor: pointer;
}
.payphone-keypad button:hover:not(:disabled) {
filter: brightness(1.1);
}
.payphone-keypad button:active:not(:disabled) {
box-shadow: 0 1px 0 #10171a;
transform: translateY(2px);
}
.payphone-keypad strong,
.payphone-keypad small {
display: block;
}
.payphone-keypad strong {
font-size: clamp(15px, 2.7vw, 24px);
line-height: 1;
}
.payphone-keypad small {
min-height: 8px;
margin-top: 2px;
color: #586368;
font-size: clamp(5px, 0.78vw, 8px);
font-weight: 800;
letter-spacing: 0.16em;
}
.payphone-keypad button:disabled {
cursor: default;
opacity: 0.68;
}
.payphone-actions {
position: absolute;
z-index: 2;
top: 76%;
left: 35.2%;
width: 53.4%;
height: 6.2%;
display: grid;
grid-template-columns: 1fr auto;
gap: 3%;
}
.payphone-action {
min-width: 0;
min-height: 0;
padding: 0 clamp(7px, 1.7vw, 16px);
border: 1px solid rgb(0 0 0 / 52%);
border-radius: 7px;
box-shadow:
inset 0 1px 0 rgb(255 255 255 / 22%),
0 3px 0 #101518;
color: white;
font-size: clamp(7px, 1.15vw, 11px);
font-weight: 800;
letter-spacing: 0.11em;
cursor: pointer;
}
.payphone-action span {
display: inline-block;
margin-right: 6px;
font-size: clamp(11px, 1.8vw, 17px);
transform: rotate(-22deg);
}
.payphone-action--call {
background: linear-gradient(#3dbd74, #187c43);
}
.payphone-action--hangup {
background: linear-gradient(#e95c55, #a12420);
}
.payphone-action--clear {
color: #cbd4d8;
background: linear-gradient(#465158, #283238);
}
.payphone-action:disabled {
cursor: default;
filter: grayscale(0.5);
opacity: 0.45;
}
.payphone-fade-enter-active,
.payphone-fade-leave-active {
transition: opacity 180ms ease;
}
.payphone-fade-enter-active .payphone-console,
.payphone-fade-leave-active .payphone-console {
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.payphone-fade-enter-from,
.payphone-fade-leave-to {
opacity: 0;
}
.payphone-fade-enter-from .payphone-console,
.payphone-fade-leave-to .payphone-console {
transform: translateY(18px) scale(0.96);
}
</style>
+27
View File
@@ -41,6 +41,33 @@ Config.Calls = {
RecentPageSize = 100,
}
Config.Payphones = {
Enabled = true,
Props = {
"prop_phonebox_01b",
"p_phonebox_01b_s",
"prop_phonebox_01a",
"prop_phonebox_04",
},
ReplacementProp = "sf_prop_sf_phonebox_01b_s",
PricePerSecond = 1,
PaymentAccount = "cash", -- cash or bank
Currency = "$",
NoAnswerTimeoutSeconds = 30,
CallerNumber = "PAYPHONE",
InteractionDistance = 1.8,
ServerValidationDistance = 3.0,
MaximumCallDistance = 4.0,
ScanDistance = 25.0,
ScanIntervalMs = 1000,
ModelLoadTimeoutMs = 5000,
Animation = {
Dictionary = "anim@scripted@payphone_hits@male@",
PedClip = "FXFR_PAV_1_INTRO_MALE",
PropClip = "FXFR_PAV_1_INTRO_PHONE",
},
}
Config.Radio = {
VoiceProvider = "auto", -- auto, yaca, pma, saltychat
DefaultVolume = 50,
+32
View File
@@ -29,7 +29,39 @@ Locales["en"] = {
voice_unavailable = "The configured phone voice service is unavailable.",
default = "The phone could not be opened.",
},
Payphone = {
Interact = "Press ~INPUT_CONTEXT~ to use the payphone.",
},
Nui = {
Payphone = {
title = "LOS SANTOS PAYPHONE",
subtitle = "PUBLIC TELEPHONE",
numberLabel = "NUMBER TO CALL",
numberPlaceholder = "Enter a phone number",
rate = "{currency}{price} / SEC",
ready = "READY",
dialing = "DIALING",
ringing = "RINGING",
connected = "CONNECTED",
callEnded = "CALL ENDED",
unavailable = "NUMBER UNAVAILABLE",
busy = "LINE BUSY",
noAnswer = "NO ANSWER",
declined = "CALL DECLINED",
disconnected = "DISCONNECTED",
insufficientFunds = "OUT OF MONEY",
invalidNumber = "ENTER A VALID NUMBER",
requestFailed = "CALL COULD NOT BE STARTED",
voiceUnavailable = "VOICE SERVICE UNAVAILABLE",
call = "CALL",
hangup = "HANG UP",
close = "Close payphone",
delete = "Delete digit",
clear = "Clear number",
keypad = "Dial pad",
elapsed = "TIME",
cost = "COST",
},
Common = {
add = "Add", back = "Back", cancel = "Cancel", clear = "Clear", close = "Close", delete = "Delete", done = "Done", edit = "Edit", home = "Home", loading = "Loading", pause = "Pause", use = "Use",
phone = "Phone", phoneStatus = "Phone status", reset = "Reset",
+1
View File
@@ -29,6 +29,7 @@ client_scripts {
'source/client/garage.lua',
'source/client/skyride.lua',
'source/bridge/client/radio.lua',
'source/client/payphones.lua',
'source/client/main.lua',
'source/client/radio.lua',
}
+318
View File
@@ -0,0 +1,318 @@
local payphone_open = false
local nearest_payphone = nil
local active_booth = nil
local active_call_id = nil
local call_channel = 0
local replacement_prop = nil
local hidden_prop = nil
local animation_scene = nil
local distance_hangup_requested = false
local configured_models = {}
for _, model_name in ipairs(Config.Payphones.Props or {}) do
configured_models[joaat(model_name)] = model_name
end
local function get_locale()
return Locales[Config.Bridge.Locale] or Locales["en"]
end
local function load_model(model_hash)
if HasModelLoaded(model_hash) then
return true
end
RequestModel(model_hash)
local deadline = GetGameTimer() + Config.Payphones.ModelLoadTimeoutMs
while not HasModelLoaded(model_hash) and GetGameTimer() < deadline do
Wait(0)
end
return HasModelLoaded(model_hash)
end
local function load_animation(dictionary)
if HasAnimDictLoaded(dictionary) then
return true
end
RequestAnimDict(dictionary)
local deadline = GetGameTimer() + Config.Payphones.ModelLoadTimeoutMs
while not HasAnimDictLoaded(dictionary) and GetGameTimer() < deadline do
Wait(0)
end
return HasAnimDictLoaded(dictionary)
end
local function leave_call_voice()
if call_channel == 0 then
return
end
if Config.Calls.VoiceProvider == "pma" and GetResourceState("pma-voice") == "started" then
exports["pma-voice"]:setCallChannel(0)
end
call_channel = 0
end
local function join_call_voice(channel)
if Config.Calls.VoiceProvider ~= "pma" or GetResourceState("pma-voice") ~= "started" then
Bridge.Debug("error", "[sky_phone] The payphone call could not join the configured voice provider.")
return false
end
call_channel = tonumber(channel) or 0
exports["pma-voice"]:setCallChannel(call_channel)
return call_channel > 0
end
local function stop_call_visuals()
local animation = Config.Payphones.Animation
local ped = PlayerPedId()
if animation_scene then
ClearPedTasks(ped)
DisposeSynchronizedScene(animation_scene)
animation_scene = nil
else
StopAnimTask(ped, animation.Dictionary, animation.PedClip, 2.0)
end
if replacement_prop and DoesEntityExist(replacement_prop) then
StopEntityAnim(replacement_prop, animation.PropClip, animation.Dictionary, -2.0)
DeleteEntity(replacement_prop)
end
replacement_prop = nil
if hidden_prop and DoesEntityExist(hidden_prop) then
SetEntityVisible(hidden_prop, true, false)
end
hidden_prop = nil
RemoveAnimDict(animation.Dictionary)
end
local function start_call_visuals()
if replacement_prop or not active_booth or not DoesEntityExist(active_booth.entity) then
return
end
local replacement_hash = joaat(Config.Payphones.ReplacementProp)
local animation = Config.Payphones.Animation
if not load_model(replacement_hash) or not load_animation(animation.Dictionary) then
Bridge.Debug("error", "[sky_phone] The payphone animation assets could not be loaded.")
SetModelAsNoLongerNeeded(replacement_hash)
return
end
local original = active_booth.entity
local coords = GetEntityCoords(original)
local rotation = GetEntityRotation(original, 2)
local replacement = CreateObjectNoOffset(
replacement_hash,
coords.x,
coords.y,
coords.z,
false,
true,
false
)
if replacement == 0 or not DoesEntityExist(replacement) then
Bridge.Debug("error", "[sky_phone] The animated payphone replacement prop could not be created.")
SetModelAsNoLongerNeeded(replacement_hash)
return
end
SetEntityRotation(replacement, rotation.x, rotation.y, rotation.z, 2, false)
FreezeEntityPosition(replacement, true)
SetEntityCollision(replacement, false, false)
SetEntityVisible(original, false, false)
hidden_prop = original
replacement_prop = replacement
local ped = PlayerPedId()
animation_scene = CreateSynchronizedScene(
coords.x,
coords.y,
coords.z,
rotation.x,
rotation.y,
rotation.z,
2
)
SetSynchronizedSceneHoldLastFrame(animation_scene, true)
TaskSynchronizedScene(
ped,
animation_scene,
animation.Dictionary,
animation.PedClip,
8.0,
-8.0,
2,
0,
1.0,
0
)
PlayEntityAnim(replacement, animation.PropClip, animation.Dictionary, 8.0, false, true, false, 0.0, 0)
SetModelAsNoLongerNeeded(replacement_hash)
end
local function booth_payload(booth)
local coords = booth.coords
return {
model = booth.model,
coords = { x = coords.x, y = coords.y, z = coords.z },
}
end
local function close_payphone()
payphone_open = false
SetNuiFocus(false, false)
SendNUIMessage({ type = "payphone:close" })
if not active_call_id then
active_booth = nil
end
end
local function open_payphone(booth)
if payphone_open or active_call_id or IsNuiFocused() then
return
end
active_booth = booth
payphone_open = true
SetNuiFocus(true, true)
SendNUIMessage({
type = "payphone:open",
data = {
currency = Config.Payphones.Currency,
maxNumberLength = Config.Sim.NumberLength,
pricePerSecond = Config.Payphones.PricePerSecond,
locales = get_locale().Nui.Payphone,
},
})
end
RegisterNUICallback("payphone:dial", function(data, cb)
if not payphone_open or not active_booth or active_call_id then
cb({ success = false, error = "invalid_request" })
return
end
local payload = booth_payload(active_booth)
payload.phoneNumber = type(data) == "table" and data.phoneNumber or nil
local result = Bridge.Callbacks.Trigger("sky_phone:payphone:dial", payload)
if result and result.success and result.data and (result.data.state == "ringing" or result.data.state == "connected") then
active_call_id = result.data.id
distance_hangup_requested = false
start_call_visuals()
end
cb(result or { success = false, error = "request_failed" })
end)
RegisterNUICallback("payphone:hangup", function(_, cb)
if not active_call_id then
cb({ success = false, error = "call_not_found" })
return
end
local result = Bridge.Callbacks.Trigger("sky_phone:payphone:hangup", { id = active_call_id })
cb(result or { success = false, error = "request_failed" })
end)
RegisterNUICallback("payphone:close", function(_, cb)
if active_call_id then
Bridge.Callbacks.Trigger("sky_phone:payphone:hangup", { id = active_call_id })
end
close_payphone()
cb({ success = true })
end)
RegisterNetEvent("sky_phone:payphone:state", function(data)
if type(data) ~= "table" then
return
end
if data.state == "ringing" or data.state == "connected" then
active_call_id = data.id
distance_hangup_requested = false
start_call_visuals()
if data.state == "connected" and data.channel and call_channel ~= tonumber(data.channel)
and not join_call_voice(data.channel)
then
Bridge.Callbacks.Trigger("sky_phone:payphone:hangup", { id = active_call_id })
end
else
active_call_id = nil
distance_hangup_requested = false
leave_call_voice()
stop_call_visuals()
end
SendNUIMessage({ type = "payphone:state", data = data })
end)
CreateThread(function()
while true do
if not Config.Payphones.Enabled or payphone_open or active_call_id then
nearest_payphone = nil
Wait(Config.Payphones.ScanIntervalMs)
else
local ped_coords = GetEntityCoords(PlayerPedId())
local closest = nil
local closest_distance = Config.Payphones.ScanDistance + 0.01
for model_hash, model_name in pairs(configured_models) do
local entity = GetClosestObjectOfType(
ped_coords.x,
ped_coords.y,
ped_coords.z,
Config.Payphones.ScanDistance,
model_hash,
false,
false,
false
)
if entity ~= 0 and DoesEntityExist(entity) then
local coords = GetEntityCoords(entity)
local distance = #(ped_coords - coords)
if distance < closest_distance then
closest_distance = distance
closest = { entity = entity, coords = coords, model = model_name, distance = distance }
end
end
end
nearest_payphone = closest
Wait(Config.Payphones.ScanIntervalMs)
end
end
end)
CreateThread(function()
while true do
if nearest_payphone and nearest_payphone.distance <= Config.Payphones.InteractionDistance and not IsNuiFocused() then
BeginTextCommandDisplayHelp("STRING")
AddTextComponentSubstringPlayerName(get_locale().Payphone.Interact)
EndTextCommandDisplayHelp(0, false, true, -1)
if IsControlJustReleased(0, 38) then
open_payphone(nearest_payphone)
end
Wait(0)
else
Wait(250)
end
end
end)
CreateThread(function()
while true do
if active_call_id and active_booth and not distance_hangup_requested then
local distance = #(GetEntityCoords(PlayerPedId()) - active_booth.coords)
if distance > Config.Payphones.MaximumCallDistance then
distance_hangup_requested = true
Bridge.Callbacks.Trigger("sky_phone:payphone:hangup", { id = active_call_id })
end
Wait(500)
else
Wait(1000)
end
end
end)
AddEventHandler("onResourceStop", function(resource_name)
if resource_name ~= GetCurrentResourceName() then
return
end
if payphone_open then
SetNuiFocus(false, false)
end
leave_call_voice()
stop_call_visuals()
end)
+247 -34
View File
@@ -90,7 +90,7 @@ end
local function send_state(call, source, state, channel)
local outgoing = source == call.caller_source
TriggerClientEvent("sky_phone:call:state", source, {
local payload = {
id = call.id,
state = state,
direction = outgoing and "outgoing" or "incoming",
@@ -98,7 +98,14 @@ local function send_state(call, source, state, channel)
startedAt = call.started_at,
answeredAt = call.answered_at,
channel = channel,
})
}
if call.payphone and outgoing then
payload.elapsedSeconds = call.payphone.billed_seconds or 0
payload.totalCost = (call.payphone.billed_seconds or 0) * call.payphone.price_per_second
TriggerClientEvent("sky_phone:payphone:state", source, payload)
return
end
TriggerClientEvent("sky_phone:call:state", source, payload)
end
local function notify_recents(device, source)
@@ -120,26 +127,33 @@ local function finish_call(call, status)
if status == "no_answer" or status == "cancelled" then
callee_status = "missed"
end
Bridge.Database.Transaction({
{
query = [[
UPDATE `sky_phone_calls`
SET `status` = ?, `ended_at` = CURRENT_TIMESTAMP, `duration_seconds` = ?
WHERE `id` = ?
]],
params = { status, duration, call.id },
},
{
query = "UPDATE `sky_phone_call_entries` SET `status` = ? WHERE `call_id` = ? AND `direction` = 'outgoing'",
params = { status, call.id },
},
{
query = "UPDATE `sky_phone_call_entries` SET `status` = ? WHERE `call_id` = ? AND `direction` = 'incoming'",
params = { callee_status, call.id },
},
})
if call.payphone and call.answered_at and status == "insufficient_funds" then
callee_status = "completed"
end
if not call.payphone then
Bridge.Database.Transaction({
{
query = [[
UPDATE `sky_phone_calls`
SET `status` = ?, `ended_at` = CURRENT_TIMESTAMP, `duration_seconds` = ?
WHERE `id` = ?
]],
params = { status, duration, call.id },
},
{
query = "UPDATE `sky_phone_call_entries` SET `status` = ? WHERE `call_id` = ? AND `direction` = 'outgoing'",
params = { status, call.id },
},
{
query = "UPDATE `sky_phone_call_entries` SET `status` = ? WHERE `call_id` = ? AND `direction` = 'incoming'",
params = { callee_status, call.id },
},
})
end
active_by_source[call.caller_source] = nil
active_by_sim[call.caller_sim_id] = nil
if call.caller_sim_id then
active_by_sim[call.caller_sim_id] = nil
end
if call.callee_source then
active_by_source[call.callee_source] = nil
end
@@ -150,8 +164,10 @@ local function finish_call(call, status)
if call.callee_source then
send_state(call, call.callee_source, callee_status)
end
notify_recents(call.caller_device, call.caller_source)
if call.callee_device then
if call.caller_device then
notify_recents(call.caller_device, call.caller_source)
end
if call.callee_device and not call.payphone then
notify_recents(call.callee_device, call.callee_source)
end
calls[call.id] = nil
@@ -440,6 +456,146 @@ Bridge.Callbacks.Register("sky_phone:calls:dial", function(source, data)
return { success = true, data = { id = id, state = "ringing", direction = "outgoing", otherNumber = number, startedAt = call.started_at } }
end)
local payphone_models = {}
for _, model_name in ipairs(Config.Payphones.Props or {}) do
payphone_models[model_name] = true
end
local function valid_payphone_position(source, data)
if type(data) ~= "table" or not payphone_models[data.model] or type(data.coords) ~= "table" then
return nil
end
local x = tonumber(data.coords.x)
local y = tonumber(data.coords.y)
local z = tonumber(data.coords.z)
if not x or not y or not z or x ~= x or y ~= y or z ~= z
or math.abs(x) > 10000.0 or math.abs(y) > 10000.0 or math.abs(z) > 2000.0
then
return nil
end
local ped = GetPlayerPed(source)
if not ped or ped == 0 then
return nil
end
local player_coords = GetEntityCoords(ped)
local booth_coords = vector3(x, y, z)
if #(player_coords - booth_coords) > Config.Payphones.ServerValidationDistance then
return nil
end
return booth_coords
end
local function payphone_terminal(number, state)
return {
id = ("payphone-terminal-%s-%s"):format(os.time(), math.random(100000, 999999)),
state = state,
direction = "outgoing",
otherNumber = number,
startedAt = os.time(),
elapsedSeconds = 0,
totalCost = 0,
}
end
Bridge.Callbacks.Register("sky_phone:payphone:dial", function(source, data)
if not Config.Payphones.Enabled or not SkyPhone.AllowOperation(source, "payphone_dial", 15, 60) then
return { success = false, error = "rate_limited" }
end
local booth_coords = valid_payphone_position(source, data)
if not booth_coords then
return { success = false, error = "invalid_payphone" }
end
if active_by_source[source] or dial_locks[source] then
return { success = false, error = "busy" }
end
local number = SkyPhoneSimNumber.Normalize(data.phoneNumber, Config.Sim.NumberLength, Config.Sim.NumberPrefix)
if not number then
return { success = false, error = "invalid_number" }
end
local price_per_second = math.max(0, math.floor(tonumber(Config.Payphones.PricePerSecond) or 0))
local available_money = Bridge.Framework.GetMoney(source, Config.Payphones.PaymentAccount)
if price_per_second > 0 and (not available_money or available_money < price_per_second) then
return { success = false, error = "insufficient_funds" }
end
dial_locks[source] = true
local targets = Bridge.Database.Query([[
SELECT s.`id`, s.`phone_number`, d.`imei`, d.`account_id`, d.`device_name`
FROM `sky_phone_sims` s LEFT JOIN `sky_phone_devices` d ON d.`sim_id` = s.`id`
WHERE s.`phone_number` = ? LIMIT 1
]], { number })
local target = targets[1]
if not target or not target.imei then
dial_locks[source] = nil
return { success = true, data = payphone_terminal(number, "unavailable") }
end
local callee_source = find_device_holder(target.imei)
if not callee_source or callee_source == source or airplane_mode(target.imei) then
dial_locks[source] = nil
return { success = true, data = payphone_terminal(number, "unavailable") }
end
if active_by_source[callee_source] or active_by_sim[target.id] or dialing_by_sim[target.id] then
dial_locks[source] = nil
return { success = true, data = payphone_terminal(number, "busy") }
end
dialing_by_sim[target.id] = true
local id = uuid()
local call = {
id = id,
caller_source = source,
caller_number = Config.Payphones.CallerNumber,
callee_source = callee_source,
callee_sim_id = target.id,
callee_number = number,
callee_device = target,
started_at = os.time(),
payphone = {
billed_seconds = 0,
coords = booth_coords,
price_per_second = price_per_second,
},
}
calls[id] = call
active_by_source[source] = id
active_by_source[callee_source] = id
active_by_sim[target.id] = id
dialing_by_sim[target.id] = nil
dial_locks[source] = nil
send_state(call, source, "ringing")
SkyPhone.OpenDeviceForCall(callee_source, target.imei)
TriggerClientEvent("sky_phone:call:incoming", callee_source, {
id = id,
state = "ringing",
direction = "incoming",
otherNumber = call.caller_number,
startedAt = call.started_at,
device = {
imei = target.imei,
name = target.device_name,
},
})
SetTimeout(math.max(1, math.floor(tonumber(Config.Payphones.NoAnswerTimeoutSeconds) or 30)) * 1000, function()
if calls[id] and not calls[id].answered_at then
finish_call(calls[id], "no_answer")
end
end)
return {
success = true,
data = {
id = id,
state = "ringing",
direction = "outgoing",
otherNumber = number,
startedAt = call.started_at,
elapsedSeconds = 0,
totalCost = 0,
},
}
end)
Bridge.Callbacks.Register("sky_phone:calls:answer", function(source, data)
local call = type(data) == "table" and calls[data.id] or nil
if not call or call.callee_source ~= source or call.answered_at then
@@ -455,8 +611,10 @@ Bridge.Callbacks.Register("sky_phone:calls:answer", function(source, data)
call.answered_at = os.time()
call.channel = next_voice_channel
next_voice_channel = next_voice_channel + 1
Bridge.Database.Query("UPDATE `sky_phone_calls` SET `status` = 'connected', `answered_at` = CURRENT_TIMESTAMP WHERE `id` = ?", { call.id })
Bridge.Database.Query("UPDATE `sky_phone_call_entries` SET `status` = 'connected' WHERE `call_id` = ?", { call.id })
if not call.payphone then
Bridge.Database.Query("UPDATE `sky_phone_calls` SET `status` = 'connected', `answered_at` = CURRENT_TIMESTAMP WHERE `id` = ?", { call.id })
Bridge.Database.Query("UPDATE `sky_phone_call_entries` SET `status` = 'connected' WHERE `call_id` = ?", { call.id })
end
send_state(call, call.caller_source, "connected", call.channel)
send_state(call, call.callee_source, "connected", call.channel)
return { success = true }
@@ -474,7 +632,21 @@ end)
Bridge.Callbacks.Register("sky_phone:calls:hangup", function(source, data)
local call_id = active_by_source[source]
local call = call_id and calls[call_id] or nil
if not call or (type(data) == "table" and data.id and data.id ~= call.id) then
if not call or (call.payphone and call.caller_source == source)
or (type(data) == "table" and data.id and data.id ~= call.id)
then
return { success = false, error = "call_not_found" }
end
finish_call(call, call.answered_at and "completed" or "cancelled")
return { success = true }
end)
Bridge.Callbacks.Register("sky_phone:payphone:hangup", function(source, data)
local call_id = active_by_source[source]
local call = call_id and calls[call_id] or nil
if not call or not call.payphone or call.caller_source ~= source
or (type(data) == "table" and data.id and data.id ~= call.id)
then
return { success = false, error = "call_not_found" }
end
finish_call(call, call.answered_at and "completed" or "cancelled")
@@ -483,17 +655,58 @@ end)
CreateThread(function()
while true do
Wait(2000)
local invalid_calls = {}
Wait(1000)
local calls_to_finish = {}
for call_id, call in pairs(calls) do
if not SkyPhone.FindDeviceSlots(call.caller_source, call.caller_device.imei)[1]
or (call.callee_source and not SkyPhone.FindDeviceSlots(call.callee_source, call.callee_device.imei)[1])
then
invalid_calls[#invalid_calls + 1] = call_id
local caller_valid = true
if call.payphone then
local caller_ped = GetPlayerPed(call.caller_source)
caller_valid = caller_ped and caller_ped ~= 0
if caller_valid then
caller_valid = #(GetEntityCoords(caller_ped) - call.payphone.coords)
<= Config.Payphones.MaximumCallDistance
end
else
caller_valid = SkyPhone.FindDeviceSlots(call.caller_source, call.caller_device.imei)[1] ~= nil
end
local callee_valid = not call.callee_source
or SkyPhone.FindDeviceSlots(call.callee_source, call.callee_device.imei)[1] ~= nil
if not caller_valid or not callee_valid then
calls_to_finish[call_id] = "disconnected"
elseif call.payphone and call.answered_at and not call.ended then
local elapsed_seconds = math.max(0, os.time() - call.answered_at)
local seconds_due = elapsed_seconds - call.payphone.billed_seconds
if seconds_due > 0 then
local amount_due = seconds_due * call.payphone.price_per_second
local charged = amount_due == 0
if amount_due > 0 then
local success, result = pcall(
Bridge.Framework.RemoveMoney,
call.caller_source,
Config.Payphones.PaymentAccount,
amount_due
)
charged = success and result and true or false
if not success then
Bridge.Debug(
"error",
"[sky_phone] Payphone billing failed for source %s: %s",
tostring(call.caller_source),
tostring(result)
)
end
end
if charged then
call.payphone.billed_seconds = elapsed_seconds
send_state(call, call.caller_source, "connected", call.channel)
else
calls_to_finish[call_id] = "insufficient_funds"
end
end
end
end
for _, call_id in ipairs(invalid_calls) do
finish_call(calls[call_id], "disconnected")
for call_id, status in pairs(calls_to_finish) do
finish_call(calls[call_id], status)
end
end
end)