mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-08-29 01:08:59 +00:00
ENH - merge payphone calling
This commit is contained in:
Binary file not shown.
@@ -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,
|
||||
@@ -940,6 +941,7 @@ onBeforeUnmount(() => {
|
||||
<template>
|
||||
<PhoneMediaCapture />
|
||||
<RadioHud />
|
||||
<PayphoneOverlay />
|
||||
<SimPhonePicker
|
||||
v-if="simPicker"
|
||||
:choices="simPicker.choices"
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 2.7 MiB |
@@ -0,0 +1,718 @@
|
||||
<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.7%;
|
||||
left: 33.5%;
|
||||
width: 54.3%;
|
||||
height: 17.3%;
|
||||
padding: 3.4%;
|
||||
overflow: hidden;
|
||||
border-radius: 6px;
|
||||
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: 44.7%;
|
||||
left: 35.3%;
|
||||
width: 50.9%;
|
||||
height: 29%;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
grid-template-rows: repeat(4, 1fr);
|
||||
gap: 2.8% 3.2%;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.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: 75.6%;
|
||||
left: 35.3%;
|
||||
width: 50.9%;
|
||||
height: 5.9%;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 3.2%;
|
||||
}
|
||||
|
||||
.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 {
|
||||
grid-column: span 2;
|
||||
background: linear-gradient(#3dbd74, #187c43);
|
||||
}
|
||||
|
||||
.payphone-action--hangup {
|
||||
grid-column: span 2;
|
||||
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>
|
||||
@@ -41,6 +41,34 @@ 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",
|
||||
HangupDurationMs = 2000,
|
||||
},
|
||||
}
|
||||
|
||||
Config.Radio = {
|
||||
VoiceProvider = "auto", -- auto, yaca, pma, saltychat
|
||||
DefaultVolume = 50,
|
||||
|
||||
@@ -29,7 +29,41 @@ Locales["en"] = {
|
||||
voice_unavailable = "The configured phone voice service is unavailable.",
|
||||
default = "The phone could not be opened.",
|
||||
},
|
||||
Payphone = {
|
||||
Interact = "Use the payphone.",
|
||||
RingingHelp = "Calling {number}... | Hang up",
|
||||
ConnectedHelp = "Connected to {number} | {duration} | {currency}{cost} | Hang up",
|
||||
},
|
||||
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",
|
||||
|
||||
@@ -32,6 +32,7 @@ client_scripts {
|
||||
'source/client/skyride.lua',
|
||||
'source/client/housing.lua',
|
||||
'source/bridge/client/radio.lua',
|
||||
'source/client/payphones.lua',
|
||||
'source/client/main.lua',
|
||||
'source/client/radio.lua',
|
||||
}
|
||||
|
||||
@@ -50,3 +50,10 @@ function Bridge.Framework.Notify(title, message, notification_type, duration)
|
||||
|
||||
Bridge.Debug("error", "[sky_phone] Notification requested for unsupported framework '%s'.", tostring(framework_name))
|
||||
end
|
||||
|
||||
function Bridge.Framework.ShowHelpNotification(message, key)
|
||||
local control = key == "E" and "~INPUT_CONTEXT~" or ("[%s]"):format(tostring(key or "E"))
|
||||
BeginTextCommandDisplayHelp("STRING")
|
||||
AddTextComponentSubstringPlayerName(("%s %s"):format(control, tostring(message or "")))
|
||||
EndTextCommandDisplayHelp(0, false, false, -1)
|
||||
end
|
||||
|
||||
@@ -0,0 +1,641 @@
|
||||
local payphone_open = false
|
||||
local nearest_payphone = nil
|
||||
local active_booth = nil
|
||||
local active_call_id = nil
|
||||
local active_call_state = nil
|
||||
local active_call_number = nil
|
||||
local active_call_elapsed_seconds = 0
|
||||
local active_call_elapsed_updated_at = 0
|
||||
local call_channel = 0
|
||||
local replacement_prop = nil
|
||||
local hidden_prop = nil
|
||||
local animation_scene = nil
|
||||
local network_animation_scene = nil
|
||||
local animation_ped = nil
|
||||
local animation_floor_z = nil
|
||||
local visuals_starting = false
|
||||
local visuals_ending = false
|
||||
local hangup_requested = false
|
||||
local remote_visuals = {}
|
||||
|
||||
local configured_models = {}
|
||||
for _, model_name in ipairs(Config.Payphones.Props or {}) do
|
||||
configured_models[joaat(model_name)] = model_name
|
||||
end
|
||||
|
||||
local function valid_visual_number(value, maximum)
|
||||
local number = tonumber(value)
|
||||
if not number or number ~= number or math.abs(number) > maximum then
|
||||
return nil
|
||||
end
|
||||
return number
|
||||
end
|
||||
|
||||
local function restore_remote_visual(id)
|
||||
local visual = remote_visuals[id]
|
||||
if not visual then
|
||||
return
|
||||
end
|
||||
remote_visuals[id] = nil
|
||||
if not visual.hidden_entity or not DoesEntityExist(visual.hidden_entity) then
|
||||
return
|
||||
end
|
||||
|
||||
for _, other in pairs(remote_visuals) do
|
||||
if other.model_hash == visual.model_hash and #(other.coords - visual.coords) < 0.5 then
|
||||
other.hidden_entity = other.hidden_entity or visual.hidden_entity
|
||||
return
|
||||
end
|
||||
end
|
||||
SetEntityVisible(visual.hidden_entity, true, false)
|
||||
end
|
||||
|
||||
RegisterNetEvent("sky_phone:payphone:visual:start", function(data)
|
||||
if type(data) ~= "table" or type(data.id) ~= "string" or type(data.model) ~= "string"
|
||||
or tonumber(data.callerSource) == GetPlayerServerId(PlayerId())
|
||||
then
|
||||
return
|
||||
end
|
||||
local model_hash = joaat(data.model)
|
||||
if not configured_models[model_hash] or type(data.coords) ~= "table" then
|
||||
return
|
||||
end
|
||||
local x = valid_visual_number(data.coords.x, 10000.0)
|
||||
local y = valid_visual_number(data.coords.y, 10000.0)
|
||||
local z = valid_visual_number(data.coords.z, 2000.0)
|
||||
if not x or not y or not z then
|
||||
return
|
||||
end
|
||||
|
||||
restore_remote_visual(data.id)
|
||||
remote_visuals[data.id] = {
|
||||
coords = vector3(x, y, z),
|
||||
model_hash = model_hash,
|
||||
hidden_entity = nil,
|
||||
}
|
||||
end)
|
||||
|
||||
RegisterNetEvent("sky_phone:payphone:visual:stop", function(data)
|
||||
if type(data) ~= "table" or type(data.id) ~= "string" then
|
||||
return
|
||||
end
|
||||
restore_remote_visual(data.id)
|
||||
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 = animation_ped or PlayerPedId()
|
||||
local visuals_active = animation_scene ~= nil or replacement_prop ~= nil
|
||||
visuals_starting = false
|
||||
visuals_ending = false
|
||||
|
||||
if visuals_active and DoesEntityExist(ped) then
|
||||
StopAnimTask(ped, animation.Dictionary, animation.PedClip, 0.0)
|
||||
ClearPedTasksImmediately(ped)
|
||||
end
|
||||
|
||||
if network_animation_scene then
|
||||
NetworkStopSynchronisedScene(network_animation_scene)
|
||||
network_animation_scene = nil
|
||||
animation_scene = nil
|
||||
elseif animation_scene then
|
||||
SetSynchronizedSceneHoldLastFrame(animation_scene, false)
|
||||
DisposeSynchronizedScene(animation_scene)
|
||||
animation_scene = nil
|
||||
end
|
||||
|
||||
animation_ped = nil
|
||||
animation_floor_z = nil
|
||||
|
||||
if replacement_prop and DoesEntityExist(replacement_prop) then
|
||||
StopEntityAnim(replacement_prop, animation.PropClip, animation.Dictionary, 0.0)
|
||||
SetEntityVisible(replacement_prop, false, false)
|
||||
SetEntityAsMissionEntity(replacement_prop, true, true)
|
||||
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 keep_animation_ped_grounded()
|
||||
if not animation_ped or not animation_floor_z or not DoesEntityExist(animation_ped) then
|
||||
return
|
||||
end
|
||||
local coords = GetEntityCoords(animation_ped)
|
||||
if coords.z >= animation_floor_z - 0.15 then
|
||||
return
|
||||
end
|
||||
SetEntityCoordsNoOffset(animation_ped, coords.x, coords.y, animation_floor_z, false, false, false)
|
||||
SetEntityVelocity(animation_ped, 0.0, 0.0, 0.0)
|
||||
end
|
||||
|
||||
local function play_hangup_visuals()
|
||||
if visuals_ending then
|
||||
return
|
||||
end
|
||||
if not animation_scene or not animation_ped or not DoesEntityExist(animation_ped) then
|
||||
stop_call_visuals()
|
||||
active_booth = nil
|
||||
return
|
||||
end
|
||||
|
||||
visuals_ending = true
|
||||
local scene = animation_scene
|
||||
local starting_phase = math.max(0.0, math.min(1.0, GetSynchronizedScenePhase(scene)))
|
||||
local duration_ms = math.max(250, math.floor(tonumber(Config.Payphones.Animation.HangupDurationMs) or 2000))
|
||||
local started_at = GetGameTimer()
|
||||
SetSynchronizedSceneRate(scene, 0.0)
|
||||
|
||||
CreateThread(function()
|
||||
while animation_scene == scene do
|
||||
local progress = math.min(1.0, (GetGameTimer() - started_at) / duration_ms)
|
||||
local phase = starting_phase * (1.0 - progress)
|
||||
SetSynchronizedScenePhase(scene, phase)
|
||||
keep_animation_ped_grounded()
|
||||
if progress >= 1.0 then
|
||||
break
|
||||
end
|
||||
Wait(0)
|
||||
end
|
||||
if animation_scene == scene then
|
||||
stop_call_visuals()
|
||||
active_booth = nil
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
local function start_call_visuals()
|
||||
if visuals_starting or replacement_prop or not active_call_id
|
||||
or not active_booth or not DoesEntityExist(active_booth.entity)
|
||||
then
|
||||
return
|
||||
end
|
||||
|
||||
visuals_starting = true
|
||||
local expected_call_id = active_call_id
|
||||
local booth = active_booth
|
||||
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.")
|
||||
visuals_starting = false
|
||||
SetModelAsNoLongerNeeded(replacement_hash)
|
||||
RemoveAnimDict(animation.Dictionary)
|
||||
return
|
||||
end
|
||||
if active_call_id ~= expected_call_id or active_booth ~= booth or not DoesEntityExist(booth.entity) then
|
||||
visuals_starting = false
|
||||
SetModelAsNoLongerNeeded(replacement_hash)
|
||||
RemoveAnimDict(animation.Dictionary)
|
||||
return
|
||||
end
|
||||
|
||||
local original = booth.entity
|
||||
local coords = GetEntityCoords(original)
|
||||
local rotation = GetEntityRotation(original, 2)
|
||||
local replacement = CreateObjectNoOffset(
|
||||
replacement_hash,
|
||||
coords.x,
|
||||
coords.y,
|
||||
coords.z,
|
||||
true,
|
||||
true,
|
||||
false
|
||||
)
|
||||
if replacement == 0 or not DoesEntityExist(replacement) then
|
||||
Bridge.Debug("error", "[sky_phone] The animated payphone replacement prop could not be created.")
|
||||
visuals_starting = false
|
||||
SetModelAsNoLongerNeeded(replacement_hash)
|
||||
RemoveAnimDict(animation.Dictionary)
|
||||
return
|
||||
end
|
||||
|
||||
SetEntityRotation(replacement, rotation.x, rotation.y, rotation.z, 2, false)
|
||||
FreezeEntityPosition(replacement, true)
|
||||
SetEntityCollision(replacement, false, false)
|
||||
local replacement_network_id = NetworkGetNetworkIdFromEntity(replacement)
|
||||
if replacement_network_id == 0 then
|
||||
Bridge.Debug("error", "[sky_phone] The animated payphone replacement prop is not networked.")
|
||||
SetEntityAsMissionEntity(replacement, true, true)
|
||||
DeleteEntity(replacement)
|
||||
visuals_starting = false
|
||||
SetModelAsNoLongerNeeded(replacement_hash)
|
||||
RemoveAnimDict(animation.Dictionary)
|
||||
return
|
||||
end
|
||||
SetNetworkIdCanMigrate(replacement_network_id, false)
|
||||
SetEntityVisible(original, false, false)
|
||||
hidden_prop = original
|
||||
replacement_prop = replacement
|
||||
|
||||
local ped = PlayerPedId()
|
||||
network_animation_scene = NetworkCreateSynchronisedScene(
|
||||
coords.x,
|
||||
coords.y,
|
||||
coords.z,
|
||||
rotation.x,
|
||||
rotation.y,
|
||||
rotation.z,
|
||||
2,
|
||||
true,
|
||||
false,
|
||||
1.0,
|
||||
0.0,
|
||||
1.0
|
||||
)
|
||||
if not network_animation_scene or network_animation_scene == -1 then
|
||||
Bridge.Debug("error", "[sky_phone] The payphone network synchronized scene could not be created.")
|
||||
network_animation_scene = nil
|
||||
stop_call_visuals()
|
||||
return
|
||||
end
|
||||
animation_ped = ped
|
||||
local ped_coords = GetEntityCoords(animation_ped)
|
||||
local found_ground, ground_z = GetGroundZFor_3dCoord(
|
||||
ped_coords.x,
|
||||
ped_coords.y,
|
||||
ped_coords.z + 1.0,
|
||||
false
|
||||
)
|
||||
animation_floor_z = found_ground and ground_z or ped_coords.z
|
||||
NetworkAddPedToSynchronisedScene(
|
||||
ped,
|
||||
network_animation_scene,
|
||||
animation.Dictionary,
|
||||
animation.PedClip,
|
||||
8.0,
|
||||
-8.0,
|
||||
2,
|
||||
0,
|
||||
1000.0,
|
||||
0
|
||||
)
|
||||
NetworkAddEntityToSynchronisedScene(
|
||||
replacement,
|
||||
network_animation_scene,
|
||||
animation.Dictionary,
|
||||
animation.PropClip,
|
||||
8.0,
|
||||
-8.0,
|
||||
0
|
||||
)
|
||||
NetworkStartSynchronisedScene(network_animation_scene)
|
||||
|
||||
local scene_deadline = GetGameTimer() + 1000
|
||||
repeat
|
||||
animation_scene = NetworkGetLocalSceneFromNetworkId(network_animation_scene)
|
||||
if animation_scene and animation_scene ~= -1 then
|
||||
break
|
||||
end
|
||||
Wait(0)
|
||||
until GetGameTimer() >= scene_deadline
|
||||
if not animation_scene or animation_scene == -1 then
|
||||
Bridge.Debug("error", "[sky_phone] The local handle for the payphone network scene was not created.")
|
||||
animation_scene = nil
|
||||
stop_call_visuals()
|
||||
return
|
||||
end
|
||||
visuals_starting = false
|
||||
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()
|
||||
local was_open = payphone_open
|
||||
payphone_open = false
|
||||
if was_open then
|
||||
SetNuiFocus(false, false)
|
||||
SendNUIMessage({ type = "payphone:close" })
|
||||
end
|
||||
if not active_call_id then
|
||||
active_booth = nil
|
||||
end
|
||||
end
|
||||
|
||||
local function format_duration(seconds)
|
||||
local duration = math.max(0, math.floor(tonumber(seconds) or 0))
|
||||
return ("%02d:%02d"):format(math.floor(duration / 60), duration % 60)
|
||||
end
|
||||
|
||||
local function replace_placeholder(value, placeholder, replacement)
|
||||
return value:gsub("{" .. placeholder .. "}", tostring(replacement))
|
||||
end
|
||||
|
||||
local function current_call_elapsed_seconds()
|
||||
if active_call_state ~= "connected" then
|
||||
return 0
|
||||
end
|
||||
return active_call_elapsed_seconds
|
||||
+ math.max(0, math.floor((GetGameTimer() - active_call_elapsed_updated_at) / 1000))
|
||||
end
|
||||
|
||||
local function call_help_message()
|
||||
local locale = get_locale().Payphone
|
||||
local message
|
||||
if active_call_state == "connected" then
|
||||
local elapsed_seconds = current_call_elapsed_seconds()
|
||||
message = locale.ConnectedHelp
|
||||
message = replace_placeholder(message, "duration", format_duration(elapsed_seconds))
|
||||
message = replace_placeholder(message, "currency", Config.Payphones.Currency)
|
||||
message = replace_placeholder(message, "cost", elapsed_seconds * (tonumber(Config.Payphones.PricePerSecond) or 0))
|
||||
else
|
||||
message = locale.RingingHelp
|
||||
end
|
||||
return replace_placeholder(message, "number", active_call_number or "")
|
||||
end
|
||||
|
||||
local function apply_active_call_state(data)
|
||||
if active_call_id ~= data.id then
|
||||
hangup_requested = false
|
||||
end
|
||||
active_call_id = data.id
|
||||
active_call_state = data.state
|
||||
active_call_number = data.otherNumber or active_call_number
|
||||
if data.state == "connected" then
|
||||
active_call_elapsed_seconds = math.max(0, math.floor(tonumber(data.elapsedSeconds) or 0))
|
||||
active_call_elapsed_updated_at = GetGameTimer()
|
||||
else
|
||||
active_call_elapsed_seconds = 0
|
||||
active_call_elapsed_updated_at = 0
|
||||
end
|
||||
end
|
||||
|
||||
local function clear_active_call_state()
|
||||
active_call_id = nil
|
||||
active_call_state = nil
|
||||
active_call_number = nil
|
||||
active_call_elapsed_seconds = 0
|
||||
active_call_elapsed_updated_at = 0
|
||||
hangup_requested = false
|
||||
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)
|
||||
local call_started = result and result.success and result.data
|
||||
and (result.data.state == "ringing" or result.data.state == "connected")
|
||||
if call_started then
|
||||
apply_active_call_state(result.data)
|
||||
end
|
||||
cb(result or { success = false, error = "request_failed" })
|
||||
if call_started then
|
||||
close_payphone()
|
||||
start_call_visuals()
|
||||
end
|
||||
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
|
||||
apply_active_call_state(data)
|
||||
close_payphone()
|
||||
start_call_visuals()
|
||||
if data.state == "connected" and data.channel and call_channel ~= tonumber(data.channel)
|
||||
and not join_call_voice(data.channel)
|
||||
then
|
||||
hangup_requested = true
|
||||
Bridge.Callbacks.Trigger("sky_phone:payphone:hangup", { id = active_call_id })
|
||||
end
|
||||
else
|
||||
clear_active_call_state()
|
||||
leave_call_voice()
|
||||
play_hangup_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 or visuals_ending 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
|
||||
Bridge.Framework.ShowHelpNotification(get_locale().Payphone.Interact, "E")
|
||||
if IsControlJustReleased(0, 38) then
|
||||
open_payphone(nearest_payphone)
|
||||
end
|
||||
Wait(0)
|
||||
else
|
||||
Wait(250)
|
||||
end
|
||||
end
|
||||
end)
|
||||
|
||||
CreateThread(function()
|
||||
while true do
|
||||
local has_visuals = next(remote_visuals) ~= nil
|
||||
if has_visuals then
|
||||
local player_coords = GetEntityCoords(PlayerPedId())
|
||||
for _, visual in pairs(remote_visuals) do
|
||||
if visual.hidden_entity and not DoesEntityExist(visual.hidden_entity) then
|
||||
visual.hidden_entity = nil
|
||||
end
|
||||
if #(player_coords - visual.coords) <= Config.Payphones.ScanDistance then
|
||||
if not visual.hidden_entity then
|
||||
local entity = GetClosestObjectOfType(
|
||||
visual.coords.x,
|
||||
visual.coords.y,
|
||||
visual.coords.z,
|
||||
1.0,
|
||||
visual.model_hash,
|
||||
false,
|
||||
false,
|
||||
false
|
||||
)
|
||||
if entity ~= 0 and DoesEntityExist(entity) then
|
||||
visual.hidden_entity = entity
|
||||
end
|
||||
end
|
||||
if visual.hidden_entity then
|
||||
SetEntityVisible(visual.hidden_entity, false, false)
|
||||
end
|
||||
end
|
||||
end
|
||||
Wait(250)
|
||||
else
|
||||
Wait(1000)
|
||||
end
|
||||
end
|
||||
end)
|
||||
|
||||
CreateThread(function()
|
||||
while true do
|
||||
local call_id = active_call_id
|
||||
local booth = active_booth
|
||||
if call_id and booth then
|
||||
keep_animation_ped_grounded()
|
||||
Bridge.Framework.ShowHelpNotification(call_help_message(), "E")
|
||||
if IsControlJustReleased(0, 38) and not hangup_requested then
|
||||
hangup_requested = true
|
||||
Bridge.Callbacks.Trigger("sky_phone:payphone:hangup", { id = call_id })
|
||||
end
|
||||
|
||||
if active_call_id == call_id and active_booth == booth then
|
||||
local distance = #(GetEntityCoords(PlayerPedId()) - booth.coords)
|
||||
if distance > Config.Payphones.MaximumCallDistance and not hangup_requested then
|
||||
hangup_requested = true
|
||||
Bridge.Callbacks.Trigger("sky_phone:payphone:hangup", { id = call_id })
|
||||
end
|
||||
end
|
||||
Wait(0)
|
||||
elseif visuals_ending then
|
||||
keep_animation_ped_grounded()
|
||||
Wait(0)
|
||||
else
|
||||
Wait(250)
|
||||
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()
|
||||
clear_active_call_state()
|
||||
local visual_ids = {}
|
||||
for id in pairs(remote_visuals) do
|
||||
visual_ids[#visual_ids + 1] = id
|
||||
end
|
||||
for _, id in ipairs(visual_ids) do
|
||||
restore_remote_visual(id)
|
||||
end
|
||||
end)
|
||||
@@ -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.elapsed_seconds or 0
|
||||
payload.totalCost = call.payphone.total_cost or 0
|
||||
TriggerClientEvent("sky_phone:payphone:state", source, payload)
|
||||
return
|
||||
end
|
||||
TriggerClientEvent("sky_phone:call:state", source, payload)
|
||||
end
|
||||
|
||||
local function notify_recents(device, source)
|
||||
@@ -109,6 +116,100 @@ local function notify_recents(device, source)
|
||||
end
|
||||
end
|
||||
|
||||
local function settle_payphone_call(call, duration)
|
||||
local payphone = call.payphone
|
||||
local elapsed_seconds = math.max(0, math.floor(tonumber(duration) or 0))
|
||||
local price_per_second = math.max(0, math.floor(tonumber(payphone.price_per_second) or 0))
|
||||
local billable_seconds = elapsed_seconds
|
||||
local total_cost = billable_seconds * price_per_second
|
||||
|
||||
if total_cost > 0 then
|
||||
local available_money = tonumber(Bridge.Framework.GetMoney(call.caller_source, Config.Payphones.PaymentAccount))
|
||||
if not available_money then
|
||||
Bridge.Debug(
|
||||
"error",
|
||||
"[sky_phone] Payphone settlement could not read the balance for source %s.",
|
||||
tostring(call.caller_source)
|
||||
)
|
||||
billable_seconds = 0
|
||||
total_cost = 0
|
||||
elseif available_money < total_cost then
|
||||
billable_seconds = math.min(elapsed_seconds, math.floor(math.max(0, available_money) / price_per_second))
|
||||
total_cost = billable_seconds * price_per_second
|
||||
end
|
||||
end
|
||||
|
||||
local charged = total_cost == 0
|
||||
if total_cost > 0 then
|
||||
local success, result = pcall(
|
||||
Bridge.Framework.RemoveMoney,
|
||||
call.caller_source,
|
||||
Config.Payphones.PaymentAccount,
|
||||
total_cost
|
||||
)
|
||||
charged = success and result and true or false
|
||||
if not success then
|
||||
Bridge.Debug(
|
||||
"error",
|
||||
"[sky_phone] Payphone settlement failed for source %s: %s",
|
||||
tostring(call.caller_source),
|
||||
tostring(result)
|
||||
)
|
||||
elseif not result then
|
||||
Bridge.Debug(
|
||||
"warn",
|
||||
"[sky_phone] Payphone settlement was rejected for source %s.",
|
||||
tostring(call.caller_source)
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
payphone.elapsed_seconds = elapsed_seconds
|
||||
payphone.total_cost = charged and total_cost or 0
|
||||
return charged and billable_seconds == elapsed_seconds
|
||||
end
|
||||
|
||||
local function send_payphone_visual(call, action, delay_ms)
|
||||
if not call.payphone then
|
||||
return
|
||||
end
|
||||
local coords = call.payphone.coords
|
||||
local payload = {
|
||||
id = call.id,
|
||||
callerSource = call.caller_source,
|
||||
model = call.payphone.model,
|
||||
coords = { x = coords.x, y = coords.y, z = coords.z },
|
||||
}
|
||||
local event_name = ("sky_phone:payphone:visual:%s"):format(action)
|
||||
local routing_bucket = call.payphone.routing_bucket
|
||||
local targets = {}
|
||||
if action == "stop" and call.payphone.visual_targets then
|
||||
for _, target in ipairs(call.payphone.visual_targets) do
|
||||
targets[#targets + 1] = target
|
||||
end
|
||||
else
|
||||
for _, player_source in ipairs(Bridge.Framework.GetPlayers()) do
|
||||
local target = tonumber(player_source) or player_source
|
||||
if GetPlayerRoutingBucket(target) == routing_bucket then
|
||||
targets[#targets + 1] = target
|
||||
end
|
||||
end
|
||||
if action == "start" then
|
||||
call.payphone.visual_targets = targets
|
||||
end
|
||||
end
|
||||
local function dispatch()
|
||||
for _, target in ipairs(targets) do
|
||||
TriggerClientEvent(event_name, target, payload)
|
||||
end
|
||||
end
|
||||
if delay_ms and delay_ms > 0 then
|
||||
SetTimeout(delay_ms, dispatch)
|
||||
else
|
||||
dispatch()
|
||||
end
|
||||
end
|
||||
|
||||
local function finish_call(call, status)
|
||||
if not call or call.ended then
|
||||
return
|
||||
@@ -116,30 +217,42 @@ local function finish_call(call, status)
|
||||
call.ended = true
|
||||
local ended_at = os.time()
|
||||
local duration = call.answered_at and math.max(0, ended_at - call.answered_at) or 0
|
||||
if call.payphone and call.answered_at and not settle_payphone_call(call, duration)
|
||||
and status ~= "disconnected"
|
||||
then
|
||||
status = "insufficient_funds"
|
||||
end
|
||||
local callee_status = 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,10 +263,19 @@ 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
|
||||
if call.payphone then
|
||||
local hangup_duration = math.max(
|
||||
250,
|
||||
math.floor(tonumber(Config.Payphones.Animation.HangupDurationMs) or 2000)
|
||||
)
|
||||
send_payphone_visual(call, "stop", hangup_duration)
|
||||
end
|
||||
calls[call.id] = nil
|
||||
end
|
||||
|
||||
@@ -440,6 +562,150 @@ 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, data.model
|
||||
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, booth_model = 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 = {
|
||||
elapsed_seconds = 0,
|
||||
total_cost = 0,
|
||||
coords = booth_coords,
|
||||
model = booth_model,
|
||||
price_per_second = price_per_second,
|
||||
routing_bucket = GetPlayerRoutingBucket(source),
|
||||
},
|
||||
}
|
||||
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")
|
||||
send_payphone_visual(call, "start")
|
||||
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 +721,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 +742,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 +765,41 @@ 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 total_cost = elapsed_seconds * call.payphone.price_per_second
|
||||
local available_money = tonumber(
|
||||
Bridge.Framework.GetMoney(call.caller_source, Config.Payphones.PaymentAccount)
|
||||
)
|
||||
if total_cost > 0 and (not available_money or available_money < total_cost) then
|
||||
calls_to_finish[call_id] = "insufficient_funds"
|
||||
elseif call.payphone.elapsed_seconds ~= elapsed_seconds then
|
||||
call.payphone.elapsed_seconds = elapsed_seconds
|
||||
call.payphone.total_cost = total_cost
|
||||
send_state(call, call.caller_source, "connected", call.channel)
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user