Files
sky_phone/sky_phone/source/client/main.lua
T
Leon.Schmidt 36b859cabb ENH - add persistent dynamic island activities (#17)
* FIX - layer dynamic island above phone camera

The phone screen stacking context kept the Dynamic Island below the physical frame and its camera lens. Render the island at device-shell level, position it around the lens, and keep expanded notification spacing in sync.

* ADD - preview all dynamic island states

Reuse the runtime component for a development-only gallery and size expanded previews to the actual phone display so they cannot be clipped.

* FIX - match dynamic island live activity behavior

Show each activity only outside its owning app, use reference-sized activity layouts, and remove the separate expand glyph in favor of direct island taps.

* FIX - refine dynamic island live controls

Keep background activity state separate from foreground visibility so the island remains visible in the compact closed-phone peek. Match the timer, stopwatch, music, collapse, and spacing behavior to the supplied references.

* FIX - keep business call availability after closing

Stop treating the closed phone UI as the end of business-call readiness. Route background service-line calls only after revalidating the employee, owned device, and registered SIM.

* FIX - remove dock apps from home grid

The stored Home layout treated dock and grid shortcuts independently, so default dock apps appeared twice. Let the dock own those default placements and migrate saved layouts by removing matching grid shortcuts.

* FIX - keep memo island active after closing

Memo recording was cancelled as soon as the UI closed, while uploads required an active UI session and reopening a locked phone forced the home route. Keep the recorder bound to the physical device, revalidate device ownership server-side for uploads, preserve the last route behind the lock screen, and render only a compact frame, display, and live Dynamic Island while closed.

* ENH - align live activity controls with phone hardware

Report compact live-activity state to the client so Space opens Home or the enabled lock screen contextually. Apply the phone hardware volume as a shared output level for app media, games, YouTube playback, radio, and auxiliary sounds, and render the volume speaker icon in grey.

* FIX - hide paused music live activity

The music island previously depended only on the selected track, which remains set after playback pauses. Require active playback as well so the island closes when music stops while preserving the track for later resume.

---------

Co-authored-by: DerEchteAlec <bycraky@gmail.com>
2026-08-20 18:59:51 +02:00

385 lines
11 KiB
Lua

SkyPhoneClient = {}
local is_open = false
local open_requested = false
local open_without_focus = false
local device_payload = nil
local equipped_phone_number = nil
local nui_generation = 0
local live_activity_active = false
local open_home_requested = false
local function get_equipped_phone_number()
if not device_payload or not device_payload.device.sim then
return nil
end
return device_payload.device.sim.number
end
local function set_equipped_phone_number(next_number)
if next_number ~= nil and type(next_number) ~= "string" then
next_number = nil
end
if next_number == equipped_phone_number then
return
end
equipped_phone_number = next_number
TriggerEvent("sky_phone:client:phoneNumberChanged", next_number)
end
local function update_equipped_phone_number(data)
set_equipped_phone_number(type(data) == "table"
and type(data.device) == "table"
and type(data.device.sim) == "table"
and data.device.sim.number
or nil)
end
local function get_authoritative_phone_number()
local result = Bridge.Callbacks.Trigger("sky_phone:device:equipped-number", {})
if type(result) ~= "table" or not result.success or type(result.data) ~= "table" then
return nil
end
local phone_number = result.data.phoneNumber
set_equipped_phone_number(phone_number)
return phone_number
end
local function get_phone_state()
return {
inCall = SkyPhoneCalls.IsActive(),
onScreen = is_open,
open = is_open,
phoneNumber = get_equipped_phone_number(),
}
end
SkyPhoneClient.GetState = get_phone_state
SkyPhoneClient.GetEquippedPhoneNumber = get_authoritative_phone_number
Bridge.Debug("debug", "[sky_phone] Client script initialized.", { always = true })
local locale, locale_name = SkyPhoneLocales.Resolve(Config.Bridge.Locale)
local function send_open_message()
if not device_payload then
return
end
local payload = device_payload
payload.lang = locale_name
payload.locales = locale.Nui
payload.fallbackLocales = Locales.en.Nui
SkyPhoneApps.SendCatalog()
SendNUIMessage({
type = "app:open",
data = payload,
openHome = open_home_requested,
})
open_home_requested = false
end
local function open_phone()
if is_open or not device_payload then
return
end
send_open_message()
end
local function close_phone(close_device_session)
local was_requested = open_requested
local was_open = is_open
open_requested = false
open_without_focus = false
TriggerEvent("sky_phone:animation:phone", false)
is_open = false
if was_open then
SkyPhoneApps.SetPhoneOpen(false)
TriggerEvent("sky_phone:nuiClosed")
TriggerEvent("sky_phone:client:phoneToggled", false)
end
SkyPhoneFocus.SetPhone(false)
if was_requested or was_open then
SendNUIMessage({ type = "app:close" })
if close_device_session ~= false then
Bridge.Callbacks.Trigger("sky_phone:device:close", {})
end
end
end
local function toggle_phone(open, no_focus)
if open ~= nil and type(open) ~= "boolean" then
Bridge.Debug("error", "[sky_phone] Rejected invalid phone toggle state.")
return false
end
if no_focus ~= nil and type(no_focus) ~= "boolean" then
Bridge.Debug("error", "[sky_phone] Rejected invalid phone toggle focus state.")
return false
end
local should_open = open
if should_open == nil then
should_open = not (is_open or open_requested)
end
if not should_open then
close_phone()
return true
end
open_without_focus = no_focus == true
if is_open or open_requested then
if is_open then
SkyPhoneFocus.SetPhone(true, open_without_focus)
end
return true
end
local result = Bridge.Callbacks.Trigger("sky_phone:device:open-request", {})
if type(result) ~= "table" or result.success ~= true then
open_without_focus = false
return false
end
return true
end
SkyPhoneClient.Toggle = toggle_phone
AddEventHandler("sky_phone:client:forceClose", function()
close_phone()
end)
if Config.Phone.DevelopmentCommand then
RegisterCommand(Config.Command, function()
if is_open or open_requested then
close_phone()
return
end
open_without_focus = false
Bridge.Callbacks.Trigger("sky_phone:device:development-open", {})
end, false)
end
RegisterCommand("sky_phone_toggle", function()
if is_open or open_requested then
close_phone()
return
end
open_without_focus = false
Bridge.Callbacks.Trigger("sky_phone:device:open-request", {})
end, false)
RegisterCommand("sky_phone_live_activity_open", function()
if not live_activity_active or is_open or open_requested then
return
end
open_home_requested = true
local result = Bridge.Callbacks.Trigger("sky_phone:device:open-request", {})
if not result or not result.success then
open_home_requested = false
end
end, false)
RegisterKeyMapping(
"sky_phone_live_activity_open",
locale.Controls.OpenPhone,
"keyboard",
"SPACE"
)
if Config.Phone.Keybind then
if type(Config.Phone.Keybind) ~= "string" or Config.Phone.Keybind == "" then
error("[sky_phone] Config.Phone.Keybind must be a non-empty keyboard key name or false.")
end
RegisterKeyMapping("sky_phone_toggle", locale.Controls.OpenPhone, "keyboard", Config.Phone.Keybind)
end
RegisterNetEvent("sky_phone:testdata:feedback", function(success, detail)
local test_data_locale = locale.TestData
local message = success and test_data_locale.Success or test_data_locale.Failed
if success and type(detail) == "string" and detail ~= "" then
message = message:gsub("{email}", detail)
end
Bridge.Framework.Notify("iFruit", message, success and "success" or "error", 7000)
end)
RegisterNUICallback("ui:ready", function(data, cb)
if type(data) ~= "table" or data.protocolVersion ~= 1 then
cb({ success = false, error = "unsupported_protocol" })
return
end
nui_generation = nui_generation + 1
-- Browser state is recreated on a CEF reload. Browser-owned focus claims
-- cannot survive unless their UI is replayed as part of this handshake.
SkyPhoneFocus.BeginNuiHydration()
Bridge.Debug("debug", "[sky_phone] NUI reported ready.", { always = true })
SkyPhoneApps.SendCatalog()
if open_requested and device_payload then
send_open_message()
end
SkyPhoneCalls.ReplayNui()
SkyPhoneSimPicker.ReplayNui()
SkyPhoneFocus.Reapply()
TriggerEvent("sky_phone:client:nuiReady", {
generation = nui_generation,
protocolVersion = 1,
})
cb({
success = true,
data = {
generation = nui_generation,
protocolVersion = 1,
},
})
end)
RegisterNUICallback("ui:opened", function(data, cb)
if type(data) ~= "table" then
cb({ success = false, error = "invalid_request" })
return
end
if not open_requested or not device_payload then
Bridge.Debug(
"warn",
"[sky_phone] Ignored a NUI open confirmation without a pending device open.",
{ always = true }
)
SendNUIMessage({ type = "app:close" })
SkyPhoneFocus.Reapply()
cb({ success = false, error = "open_not_requested" })
return
end
local was_open = is_open
is_open = true
SkyPhoneApps.SetPhoneOpen(true)
if not was_open then
TriggerEvent("sky_phone:client:phoneToggled", true)
end
SkyPhoneFocus.SetPhone(true, open_without_focus)
TriggerEvent("sky_phone:animation:phone", true)
cb({ success = true })
end)
RegisterNUICallback("ui:input-focus", function(data, cb)
if type(data) ~= "table" or type(data.active) ~= "boolean" then
cb({ success = false, error = "invalid_request" })
return
end
SkyPhoneFocus.SetTextInputFocused(data.active and (is_open or open_requested))
cb({ success = true })
end)
RegisterNUICallback("ui:live-activity", function(data, cb)
if type(data) ~= "table" or type(data.active) ~= "boolean" then
cb({ success = false, error = "invalid_request" })
return
end
live_activity_active = data.active
cb({ success = true })
end)
RegisterNUICallback("close", function(data, cb)
if type(data) ~= "table" then
cb({ success = false, error = "invalid_request" })
return
end
close_phone()
cb({ success = true })
end)
RegisterNetEvent("sky_phone:device:open", function(data)
if type(data) ~= "table" or type(data.device) ~= "table" or type(data.device.imei) ~= "string" then
Bridge.Debug("error", "[sky_phone] Rejected invalid device open data.")
return
end
Bridge.Debug(
"debug",
"[sky_phone] Client received device open for IMEI %s account_linked=%s.",
tostring(data.device.imei),
tostring(data.account ~= nil),
{ always = true }
)
device_payload = data
update_equipped_phone_number(data)
open_requested = true
if is_open then
SkyPhoneApps.SendCatalog()
SendNUIMessage({ type = "device:updated", data = data })
return
end
open_phone()
end)
RegisterNetEvent("sky_phone:device:updated", function(data)
if type(data) ~= "table" or type(data.device) ~= "table" or type(data.device.imei) ~= "string" then
Bridge.Debug("error", "[sky_phone] Rejected invalid device update data.")
return
end
device_payload = data
update_equipped_phone_number(data)
SendNUIMessage({ type = "device:updated", data = data })
end)
RegisterNetEvent("sky_phone:device:invalidated", function()
TriggerEvent("sky_phone:animation:reset")
close_phone(false)
device_payload = nil
update_equipped_phone_number(nil)
live_activity_active = false
open_home_requested = false
end)
RegisterNetEvent("sky_phone:device:error", function(error_code)
if not is_open and not open_requested then
open_without_focus = false
end
Bridge.Debug(
"debug",
"[sky_phone] Client received device error: %s.",
tostring(error_code),
{ always = true }
)
local message = locale.DeviceErrors[error_code] or locale.DeviceErrors.default
Bridge.Framework.Notify("iFruit", message, "error", 5000)
end)
CreateThread(function()
if Config.Phone.DevelopmentCommand then
TriggerEvent("chat:addSuggestion", "/" .. Config.Command, locale.CommandDescription)
end
if Config.TestData.Enabled then
TriggerEvent("chat:addSuggestion", "/" .. Config.TestData.Command, locale.TestData.CommandDescription)
end
end)
AddEventHandler("onResourceStop", function(resource_name)
if resource_name ~= GetCurrentResourceName() then
return
end
is_open = false
open_requested = false
open_without_focus = false
TriggerEvent("sky_phone:animation:reset")
SkyPhoneCalls.Reset()
SkyPhoneSimPicker.Reset()
SkyPhoneFocus.Reset()
if Config.Phone.DevelopmentCommand then
TriggerEvent("chat:removeSuggestion", "/" .. Config.Command)
end
if Config.TestData.Enabled then
TriggerEvent("chat:removeSuggestion", "/" .. Config.TestData.Command)
end
end)