mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-09-03 17:00:53 +00:00
ADD - implement weather app camera views
Add the Weather phone experience with live widgets, configuration, and localized status details.\n\nProvide server-authorized, rate-limited remote weather camera sessions with configurable camera locations and safe client cleanup. Include test-server support and weather store coverage.
This commit is contained in:
@@ -4,7 +4,7 @@
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "concurrently -k -n FRONTEND,BACKEND -c cyan,green \"vite --port 5174 --strictPort\" \"node testserver/index.cjs 3002\"",
|
||||
"dev": "concurrently -k -n FRONTEND,BACKEND -c cyan,green \"vite\" \"node testserver/index.cjs\"",
|
||||
"build": "pnpm typecheck && pnpm build-only",
|
||||
"build-only": "vite build && node build.cjs",
|
||||
"preview": "vite preview",
|
||||
|
||||
+66
-26
@@ -269,7 +269,7 @@ const phoneFrameImage = computed(
|
||||
let clockTicker: ReturnType<typeof setInterval> | undefined
|
||||
let unlockTimer: number | undefined
|
||||
let passcodeLockTimer: number | undefined
|
||||
let unlockedServicesFrame: number | undefined
|
||||
let unlockedServicesIdle: number | undefined
|
||||
|
||||
function getViewportScale(): number {
|
||||
const heightScale = window.innerHeight / REFERENCE_VIEWPORT_HEIGHT
|
||||
@@ -296,27 +296,62 @@ function hydratePhone(payload: PhoneOpenPayload): void {
|
||||
widgets.hydrate(payload.device?.data.widgets?.payload)
|
||||
}
|
||||
|
||||
function loadUnlockedPhoneData(): void {
|
||||
if (unlockedServicesLoaded.value) return
|
||||
unlockedServicesLoaded.value = true
|
||||
function cancelUnlockedPhoneDataLoad(): void {
|
||||
if (unlockedServicesIdle === undefined) return
|
||||
if (typeof window.cancelIdleCallback === 'function') {
|
||||
window.cancelIdleCallback(unlockedServicesIdle)
|
||||
} else {
|
||||
window.clearTimeout(unlockedServicesIdle)
|
||||
}
|
||||
unlockedServicesIdle = undefined
|
||||
}
|
||||
|
||||
// Let the lock screen/home screen paint before background app requests compete
|
||||
// with the first visible NUI frame.
|
||||
unlockedServicesFrame = window.requestAnimationFrame(() => {
|
||||
unlockedServicesFrame = undefined
|
||||
async function bootstrapUnlockedPhoneData(): Promise<void> {
|
||||
const tasks: Array<() => Promise<unknown> | void> = [
|
||||
() => calls.bootstrap(),
|
||||
() => messages.loadConversations(),
|
||||
() => billing.loadOverview(),
|
||||
() => mail.bootstrap(account.email),
|
||||
() => {
|
||||
if (account.email) return marketplace.loadCounts()
|
||||
marketplace.setCounts({ active: 0, unread: 0 })
|
||||
},
|
||||
() => (account.email ? darkchat.bootstrap() : undefined),
|
||||
]
|
||||
|
||||
for (const task of tasks) {
|
||||
if (!phone.isOpen || isLocked.value) {
|
||||
unlockedServicesLoaded.value = false
|
||||
return
|
||||
}
|
||||
try {
|
||||
await task()
|
||||
} catch (error) {
|
||||
console.error('[sky_phone] Failed to bootstrap unlocked phone data.', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void mail.bootstrap(account.email)
|
||||
if (account.email) void marketplace.loadCounts()
|
||||
else marketplace.setCounts({ active: 0, unread: 0 })
|
||||
void calls.bootstrap()
|
||||
void messages.loadConversations()
|
||||
void billing.loadOverview()
|
||||
if (account.email) void darkchat.bootstrap()
|
||||
})
|
||||
function loadUnlockedPhoneData(): void {
|
||||
if (unlockedServicesLoaded.value) return
|
||||
unlockedServicesLoaded.value = true
|
||||
|
||||
const startBootstrap = () => {
|
||||
unlockedServicesIdle = undefined
|
||||
if (!phone.isOpen || isLocked.value) {
|
||||
unlockedServicesLoaded.value = false
|
||||
return
|
||||
}
|
||||
void bootstrapUnlockedPhoneData()
|
||||
}
|
||||
|
||||
if (typeof window.requestIdleCallback === 'function') {
|
||||
unlockedServicesIdle = window.requestIdleCallback(startBootstrap, {
|
||||
timeout: 1500,
|
||||
})
|
||||
} else {
|
||||
unlockedServicesIdle = window.setTimeout(startBootstrap, 0)
|
||||
}
|
||||
}
|
||||
|
||||
async function hydrateDevelopmentPhone(): Promise<void> {
|
||||
@@ -727,6 +762,16 @@ function updateViewportScale(): void {
|
||||
viewportScale.value = getViewportScale()
|
||||
}
|
||||
|
||||
function completeUnlock(): void {
|
||||
if (!isUnlocking.value) return
|
||||
if (unlockTimer !== undefined) {
|
||||
window.clearTimeout(unlockTimer)
|
||||
unlockTimer = undefined
|
||||
}
|
||||
isUnlocking.value = false
|
||||
loadUnlockedPhoneData()
|
||||
}
|
||||
|
||||
function finishUnlock(): void {
|
||||
if (!isLocked.value) return
|
||||
isUnlocking.value = true
|
||||
@@ -735,7 +780,8 @@ function finishUnlock(): void {
|
||||
passcodeError.value = ''
|
||||
|
||||
unlockTimer = window.setTimeout(() => {
|
||||
isUnlocking.value = false
|
||||
unlockTimer = undefined
|
||||
completeUnlock()
|
||||
}, 720)
|
||||
|
||||
if (pendingUnlockRoute.value) {
|
||||
@@ -743,7 +789,6 @@ function finishUnlock(): void {
|
||||
pendingUnlockRoute.value = null
|
||||
window.setTimeout(() => void router.push(routePath), 0)
|
||||
}
|
||||
loadUnlockedPhoneData()
|
||||
}
|
||||
|
||||
function unlockPhone(): void {
|
||||
@@ -907,10 +952,7 @@ watch(
|
||||
(isOpen) => {
|
||||
if (unlockTimer !== undefined) window.clearTimeout(unlockTimer)
|
||||
if (!isOpen) {
|
||||
if (unlockedServicesFrame !== undefined) {
|
||||
window.cancelAnimationFrame(unlockedServicesFrame)
|
||||
unlockedServicesFrame = undefined
|
||||
}
|
||||
cancelUnlockedPhoneDataLoad()
|
||||
activitySuspended.value = false
|
||||
weather.stop()
|
||||
controlCenterOpened.value = false
|
||||
@@ -956,9 +998,7 @@ watch(
|
||||
)
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (unlockedServicesFrame !== undefined) {
|
||||
window.cancelAnimationFrame(unlockedServicesFrame)
|
||||
}
|
||||
cancelUnlockedPhoneDataLoad()
|
||||
weather.stop()
|
||||
if (clockTicker) clearInterval(clockTicker)
|
||||
if (unlockTimer !== undefined) window.clearTimeout(unlockTimer)
|
||||
@@ -1063,7 +1103,7 @@ onBeforeUnmount(() => {
|
||||
:opened="controlCenterOpened"
|
||||
@close="controlCenterOpened = false"
|
||||
/>
|
||||
<Transition name="lock-screen">
|
||||
<Transition name="lock-screen" @after-leave="completeUnlock">
|
||||
<PhoneLockScreen
|
||||
v-if="isLocked"
|
||||
:notifications="notifications.lockScreenNotifications"
|
||||
|
||||
@@ -2841,13 +2841,68 @@ button {
|
||||
.weather-app--rain .weather-app__backdrop,
|
||||
.weather-app--thunder .weather-app__backdrop {
|
||||
background:
|
||||
repeating-linear-gradient(
|
||||
104deg,
|
||||
transparent 0 22px,
|
||||
#bcecff17 23px 25px,
|
||||
transparent 26px 49px
|
||||
),
|
||||
linear-gradient(165deg, #354b69 0%, #253952 46%, #101b2d 100%);
|
||||
radial-gradient(ellipse at 18% 10%, #b9d0e126 0%, transparent 34%),
|
||||
radial-gradient(ellipse at 82% 28%, #8faec126 0%, transparent 38%),
|
||||
linear-gradient(165deg, #3f5874 0%, #293e58 44%, #111d30 100%);
|
||||
}
|
||||
.weather-app__rain {
|
||||
position: absolute;
|
||||
z-index: -1;
|
||||
inset: 0;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
.weather-app__rain::after {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
#d8edfa0b 0%,
|
||||
transparent 30%,
|
||||
#07112238 100%
|
||||
);
|
||||
content: '';
|
||||
}
|
||||
.weather-app__rain i {
|
||||
position: absolute;
|
||||
top: -24px;
|
||||
left: var(--rain-left);
|
||||
width: 1.5px;
|
||||
height: var(--rain-height);
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
transparent,
|
||||
#c9efff 32%,
|
||||
#82ccea 78%,
|
||||
transparent
|
||||
);
|
||||
filter: drop-shadow(0 0 2px #9fdfff42);
|
||||
opacity: 0;
|
||||
transform: rotate(12deg);
|
||||
animation: weather-rain-fall var(--rain-duration) linear var(--rain-delay)
|
||||
infinite;
|
||||
}
|
||||
@keyframes weather-rain-fall {
|
||||
0% {
|
||||
translate: -18px -5vh;
|
||||
opacity: 0;
|
||||
}
|
||||
12% {
|
||||
opacity: var(--rain-opacity);
|
||||
}
|
||||
78% {
|
||||
opacity: var(--rain-opacity);
|
||||
}
|
||||
100% {
|
||||
translate: 42px 110vh;
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.weather-app__rain i {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
.weather-app--thunder .weather-app__backdrop {
|
||||
background:
|
||||
@@ -3460,6 +3515,119 @@ button {
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.weather-camera-card {
|
||||
padding-bottom: 12px;
|
||||
}
|
||||
.weather-camera-preview {
|
||||
position: relative;
|
||||
min-height: 146px;
|
||||
overflow: hidden;
|
||||
background:
|
||||
radial-gradient(circle at 75% 20%, #bfe8ff9c, transparent 28%),
|
||||
linear-gradient(160deg, #82b4d5 0%, #496783 52%, #27364c 100%);
|
||||
}
|
||||
.weather-camera-preview--rain,
|
||||
.weather-camera-preview--thunder {
|
||||
background:
|
||||
radial-gradient(circle at 70% 18%, #9cb4c380, transparent 26%),
|
||||
linear-gradient(160deg, #667d8d 0%, #34495f 50%, #1a2839 100%);
|
||||
}
|
||||
.weather-camera-preview--clear,
|
||||
.weather-camera-preview--sunny {
|
||||
background:
|
||||
radial-gradient(circle at 76% 19%, #fff4c9 0 5%, #ffd78370 6%, transparent 29%),
|
||||
linear-gradient(160deg, #72b7df 0%, #4d86b2 55%, #35506e 100%);
|
||||
}
|
||||
.weather-camera-preview::after {
|
||||
position: absolute;
|
||||
inset: -50% 0 auto;
|
||||
height: 3px;
|
||||
background: #ffffff38;
|
||||
box-shadow: 0 0 12px #fff8;
|
||||
content: '';
|
||||
animation: weather-camera-scan 4s linear infinite;
|
||||
}
|
||||
.weather-camera-skyline {
|
||||
position: absolute;
|
||||
right: -5%;
|
||||
bottom: 0;
|
||||
left: -5%;
|
||||
height: 51%;
|
||||
background:
|
||||
linear-gradient(90deg, transparent 0 5%, #142333 5% 17%, transparent 17% 21%, #192b3c 21% 34%, transparent 34% 42%, #11202f 42% 60%, transparent 60% 65%, #182a3b 65% 76%, transparent 76% 81%, #102131 81% 95%, transparent 95%),
|
||||
linear-gradient(0deg, #111c2a 0 18%, transparent 18%);
|
||||
clip-path: polygon(0 43%, 9% 43%, 9% 22%, 18% 22%, 18% 57%, 27% 57%, 27% 10%, 37% 10%, 37% 48%, 48% 48%, 48% 30%, 61% 30%, 61% 62%, 71% 62%, 71% 16%, 83% 16%, 83% 49%, 100% 49%, 100% 100%, 0 100%);
|
||||
opacity: 0.85;
|
||||
}
|
||||
.weather-camera-meta,
|
||||
.weather-camera-copy {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
right: 12px;
|
||||
left: 12px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
text-shadow: 0 2px 8px #000c;
|
||||
}
|
||||
.weather-camera-meta {
|
||||
top: 11px;
|
||||
align-items: center;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.weather-camera-live {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
}
|
||||
.weather-camera-live i {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: #ff4e5b;
|
||||
box-shadow: 0 0 0 4px #ff4e5b2e;
|
||||
animation: weather-camera-pulse 1.6s ease-in-out infinite;
|
||||
}
|
||||
.weather-camera-copy {
|
||||
bottom: 10px;
|
||||
align-items: flex-end;
|
||||
flex-direction: column;
|
||||
}
|
||||
.weather-camera-copy strong {
|
||||
font-size: 16px;
|
||||
}
|
||||
.weather-camera-copy span {
|
||||
color: #ffffffc4;
|
||||
font-size: 10px;
|
||||
}
|
||||
.weather-camera-action {
|
||||
padding: 11px 12px 0;
|
||||
}
|
||||
.weather-camera-action button span {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 7px;
|
||||
}
|
||||
.weather-camera-action p {
|
||||
margin: 8px 3px 0;
|
||||
color: #ffd2d2;
|
||||
font-size: 10px;
|
||||
text-align: center;
|
||||
}
|
||||
.weather-camera-unavailable {
|
||||
padding: 16px;
|
||||
color: #ffffffb3;
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
}
|
||||
@keyframes weather-camera-scan {
|
||||
to { transform: translateY(290px); }
|
||||
}
|
||||
@keyframes weather-camera-pulse {
|
||||
50% { opacity: 0.45; }
|
||||
}
|
||||
.weather-hourly {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, minmax(0, 1fr));
|
||||
|
||||
@@ -56,11 +56,18 @@ const phone = usePhoneStore()
|
||||
const calls = useCallsStore()
|
||||
const messages = useMessagesStore()
|
||||
const router = useRouter()
|
||||
const clock = useClockService()
|
||||
const usesClock = computed(() =>
|
||||
['clock', 'date'].includes(props.instance.kind),
|
||||
)
|
||||
const usesBank = computed(() =>
|
||||
['transactions', 'wallet'].includes(props.instance.kind),
|
||||
)
|
||||
const usesContacts = computed(() => props.instance.kind === 'contacts')
|
||||
const clock = useClockService(usesClock)
|
||||
const weather = useWeatherService()
|
||||
const music = useMusicService()
|
||||
const bank = useBankService()
|
||||
const contactsService = useContactsService()
|
||||
const bank = useBankService(usesBank)
|
||||
const contactsService = useContactsService(usesContacts)
|
||||
const isDragging = ref(false)
|
||||
const dragOffset = ref({ x: 0, y: 0 })
|
||||
const suppressClick = ref(false)
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
kSheet,
|
||||
kToggle,
|
||||
} from 'konsta/vue'
|
||||
import { ref, watch } from 'vue'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import SpringboardWidget from '@/components/SpringboardWidget.vue'
|
||||
import { WIDGET_REGISTRY_BY_KIND } from '@/config/widgets'
|
||||
@@ -32,7 +32,9 @@ const emit = defineEmits<{
|
||||
save: [size: WidgetSize, settings: WidgetSettings]
|
||||
}>()
|
||||
const phone = usePhoneStore()
|
||||
const contactsService = useContactsService()
|
||||
const contactsService = useContactsService(
|
||||
computed(() => props.opened && props.instance?.kind === 'contacts'),
|
||||
)
|
||||
const size = ref<WidgetSize>('small')
|
||||
const showDate = ref(true)
|
||||
const balanceSource = ref<'bank' | 'cash'>('bank')
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import {
|
||||
computed,
|
||||
onBeforeUnmount,
|
||||
onMounted,
|
||||
ref,
|
||||
toValue,
|
||||
watch,
|
||||
type MaybeRefOrGetter,
|
||||
} from 'vue'
|
||||
|
||||
import { useBankingStore } from '@/stores/banking'
|
||||
import { useCallsStore } from '@/stores/calls'
|
||||
@@ -9,23 +17,43 @@ import { useWeatherStore } from '@/stores/weather'
|
||||
const now = ref(new Date())
|
||||
let clockConsumers = 0
|
||||
let clockInterval: number | undefined
|
||||
let contactsRequest: Promise<void> | undefined
|
||||
|
||||
export function useClockService() {
|
||||
export function useClockService(enabled: MaybeRefOrGetter<boolean> = true) {
|
||||
const phone = usePhoneStore()
|
||||
onMounted(() => {
|
||||
let active = false
|
||||
let mounted = false
|
||||
|
||||
function syncClock(): void {
|
||||
const shouldBeActive = mounted && toValue(enabled)
|
||||
if (shouldBeActive === active) return
|
||||
|
||||
active = shouldBeActive
|
||||
if (!active) {
|
||||
clockConsumers -= 1
|
||||
if (clockConsumers === 0 && clockInterval !== undefined) {
|
||||
window.clearInterval(clockInterval)
|
||||
clockInterval = undefined
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
clockConsumers += 1
|
||||
if (clockInterval === undefined) {
|
||||
clockInterval = window.setInterval(() => {
|
||||
now.value = new Date()
|
||||
}, 1000)
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => toValue(enabled), syncClock)
|
||||
onMounted(() => {
|
||||
mounted = true
|
||||
syncClock()
|
||||
})
|
||||
onBeforeUnmount(() => {
|
||||
clockConsumers -= 1
|
||||
if (clockConsumers === 0 && clockInterval !== undefined) {
|
||||
window.clearInterval(clockInterval)
|
||||
clockInterval = undefined
|
||||
}
|
||||
mounted = false
|
||||
syncClock()
|
||||
})
|
||||
return {
|
||||
date: computed(() =>
|
||||
@@ -92,7 +120,7 @@ export function useMusicService() {
|
||||
}
|
||||
}
|
||||
|
||||
export function useBankService() {
|
||||
export function useBankService(enabled: MaybeRefOrGetter<boolean> = true) {
|
||||
const banking = useBankingStore()
|
||||
const overview = computed(
|
||||
() =>
|
||||
@@ -130,13 +158,18 @@ export function useBankService() {
|
||||
],
|
||||
},
|
||||
)
|
||||
onMounted(() => {
|
||||
if (!banking.overview && !banking.isLoading) void banking.load()
|
||||
})
|
||||
function loadIfNeeded(): void {
|
||||
if (toValue(enabled) && !banking.overview && !banking.isLoading) {
|
||||
void banking.load()
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => toValue(enabled), loadIfNeeded)
|
||||
onMounted(loadIfNeeded)
|
||||
return { overview }
|
||||
}
|
||||
|
||||
export function useContactsService() {
|
||||
export function useContactsService(enabled: MaybeRefOrGetter<boolean> = true) {
|
||||
const calls = useCallsStore()
|
||||
const contacts = computed(() =>
|
||||
calls.contacts.length
|
||||
@@ -148,8 +181,15 @@ export function useContactsService() {
|
||||
{ id: 'mock-liam', name: 'Liam', phone_number: '555-0177' },
|
||||
],
|
||||
)
|
||||
onMounted(() => {
|
||||
if (!calls.contacts.length) void calls.loadContacts()
|
||||
})
|
||||
function loadIfNeeded(): void {
|
||||
if (!toValue(enabled) || calls.contacts.length || contactsRequest) return
|
||||
|
||||
contactsRequest = calls.loadContacts().finally(() => {
|
||||
contactsRequest = undefined
|
||||
})
|
||||
}
|
||||
|
||||
watch(() => toValue(enabled), loadIfNeeded)
|
||||
onMounted(loadIfNeeded)
|
||||
return { contacts }
|
||||
}
|
||||
|
||||
@@ -2338,6 +2338,28 @@ const defaultLocales: LocaleTree = {
|
||||
unavailable: 'Weather is unavailable',
|
||||
stale: 'Showing the last available weather update.',
|
||||
tryAgain: 'Try Again',
|
||||
webcam: {
|
||||
title: 'Weather Cameras',
|
||||
live: 'LIVE',
|
||||
view: 'View Live Camera',
|
||||
subtitle: 'A live look at current conditions',
|
||||
unavailable: 'No weather cameras are available.',
|
||||
locations: {
|
||||
legion_square: 'Legion Square',
|
||||
sandy_shores: 'Sandy Shores',
|
||||
cayo_airstrip: 'Cayo Perico Airstrip',
|
||||
},
|
||||
errors: {
|
||||
camera_disabled: 'Weather cameras are currently disabled.',
|
||||
camera_not_found: 'This weather camera is no longer available.',
|
||||
camera_requires_safe_position:
|
||||
'Stand still and exit your vehicle before opening a camera.',
|
||||
camera_active: 'A weather camera is already open.',
|
||||
rate_limited: 'Too many camera requests. Try again shortly.',
|
||||
request_failed: 'The weather camera could not be opened.',
|
||||
not_authenticated: 'Unlock the phone before opening a camera.',
|
||||
},
|
||||
},
|
||||
regions: {
|
||||
los_santos: 'Los Santos',
|
||||
blaine_county: 'Blaine County',
|
||||
|
||||
@@ -60,4 +60,25 @@ describe('weather store', () => {
|
||||
expect(weather.forecast).toBe(previous)
|
||||
expect(weather.error).toBe('offline')
|
||||
})
|
||||
|
||||
it('loads the server-approved camera list and opens a camera by id', async () => {
|
||||
vi.mocked(nuiCall)
|
||||
.mockResolvedValueOnce({
|
||||
data: { cameras: [{ id: 'legion_square', region: 'los_santos' }] },
|
||||
success: true,
|
||||
})
|
||||
.mockResolvedValueOnce({ success: true })
|
||||
const weather = useWeatherStore()
|
||||
|
||||
await weather.loadCameras()
|
||||
const opened = await weather.openCamera('legion_square')
|
||||
|
||||
expect(weather.cameras).toEqual([
|
||||
{ id: 'legion_square', region: 'los_santos' },
|
||||
])
|
||||
expect(nuiCall).toHaveBeenLastCalledWith('weather:camera-open', {
|
||||
id: 'legion_square',
|
||||
})
|
||||
expect(opened).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
import type { RawWeatherSnapshot, WeatherForecast } from '@/types/weather'
|
||||
import type {
|
||||
RawWeatherSnapshot,
|
||||
WeatherCamera,
|
||||
WeatherForecast,
|
||||
} from '@/types/weather'
|
||||
import { nuiCall } from '@/utils/nui'
|
||||
import { buildWeatherForecast } from '@/utils/weather'
|
||||
|
||||
@@ -9,12 +13,45 @@ const REFRESH_INTERVAL = 30_000
|
||||
export const useWeatherStore = defineStore('weather', {
|
||||
state: () => ({
|
||||
error: null as string | null,
|
||||
cameraError: null as string | null,
|
||||
cameras: [] as WeatherCamera[],
|
||||
camerasLoaded: false,
|
||||
forecast: null as WeatherForecast | null,
|
||||
intervalId: undefined as number | undefined,
|
||||
isLoading: false,
|
||||
isOpeningCamera: false,
|
||||
lastFetchedAt: 0,
|
||||
}),
|
||||
actions: {
|
||||
async loadCameras(force = false): Promise<void> {
|
||||
if (this.camerasLoaded && !force) return
|
||||
const response = await nuiCall<{ cameras: WeatherCamera[] }>(
|
||||
'weather:cameras',
|
||||
)
|
||||
this.camerasLoaded = true
|
||||
if (!response.success || !Array.isArray(response.data?.cameras)) {
|
||||
this.cameraError = response.error ?? 'request_failed'
|
||||
return
|
||||
}
|
||||
this.cameras = response.data.cameras.filter(
|
||||
(camera) =>
|
||||
typeof camera?.id === 'string' &&
|
||||
['los_santos', 'blaine_county', 'cayo_perico'].includes(
|
||||
camera.region,
|
||||
),
|
||||
)
|
||||
this.cameraError = null
|
||||
},
|
||||
async openCamera(id: string): Promise<boolean> {
|
||||
if (this.isOpeningCamera) return false
|
||||
this.isOpeningCamera = true
|
||||
const response = await nuiCall('weather:camera-open', { id })
|
||||
this.isOpeningCamera = false
|
||||
this.cameraError = response.success
|
||||
? null
|
||||
: (response.error ?? 'request_failed')
|
||||
return response.success
|
||||
},
|
||||
async refresh(force = false): Promise<void> {
|
||||
if (this.isLoading) return
|
||||
if (!force && Date.now() - this.lastFetchedAt < REFRESH_INTERVAL) return
|
||||
@@ -31,6 +68,7 @@ export const useWeatherStore = defineStore('weather', {
|
||||
},
|
||||
start(): void {
|
||||
void this.refresh(true)
|
||||
void this.loadCameras()
|
||||
if (this.intervalId !== undefined) return
|
||||
this.intervalId = window.setInterval(() => void this.refresh(true), REFRESH_INTERVAL)
|
||||
},
|
||||
|
||||
@@ -10,6 +10,11 @@ export type WeatherConditionId =
|
||||
|
||||
export type WeatherRegionId = 'los_santos' | 'blaine_county' | 'cayo_perico'
|
||||
|
||||
export type WeatherCamera = {
|
||||
id: string
|
||||
region: WeatherRegionId
|
||||
}
|
||||
|
||||
export type WeatherClock = {
|
||||
day: number
|
||||
hour: number
|
||||
|
||||
@@ -14,7 +14,7 @@ export async function nuiCall<T = unknown>(
|
||||
'apiPort',
|
||||
)
|
||||
const baseUrl = import.meta.env.DEV
|
||||
? `http://localhost:${developmentPort ?? '3002'}/api`
|
||||
? `http://localhost:${developmentPort ?? '3001'}/api`
|
||||
: `https://${resourceName}`
|
||||
const requestData = import.meta.env.DEV
|
||||
? {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { kCard, kLink, kNavbar, kPage, kPreloader } from 'konsta/vue'
|
||||
import { kButton, kCard, kLink, kNavbar, kPage, kPreloader } from 'konsta/vue'
|
||||
import {
|
||||
CloudSun,
|
||||
Droplets,
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
RefreshCw,
|
||||
ThermometerSun,
|
||||
Umbrella,
|
||||
Video,
|
||||
Wind,
|
||||
} from 'lucide-vue-next'
|
||||
import { computed } from 'vue'
|
||||
@@ -20,6 +21,38 @@ import type { WeatherConditionId } from '@/types/weather'
|
||||
const phone = usePhoneStore()
|
||||
const weather = useWeatherStore()
|
||||
const forecast = computed(() => weather.forecast)
|
||||
const selectedCamera = computed(
|
||||
() =>
|
||||
weather.cameras.find((camera) => camera.region === forecast.value?.region) ??
|
||||
weather.cameras[0],
|
||||
)
|
||||
const rainy = computed(
|
||||
() =>
|
||||
forecast.value?.condition === 'rain' ||
|
||||
forecast.value?.condition === 'thunder',
|
||||
)
|
||||
const rainDrops = [
|
||||
['4%', '17px', '1.35s', '-0.3s', '0.32'],
|
||||
['11%', '11px', '1.7s', '-1.1s', '0.2'],
|
||||
['18%', '21px', '1.45s', '-0.8s', '0.38'],
|
||||
['25%', '13px', '1.9s', '-1.6s', '0.24'],
|
||||
['33%', '18px', '1.55s', '-0.2s', '0.3'],
|
||||
['40%', '10px', '1.75s', '-1.35s', '0.18'],
|
||||
['47%', '22px', '1.4s', '-0.65s', '0.36'],
|
||||
['54%', '14px', '2s', '-1.8s', '0.22'],
|
||||
['61%', '19px', '1.6s', '-0.45s', '0.34'],
|
||||
['68%', '12px', '1.8s', '-1.2s', '0.2'],
|
||||
['75%', '20px', '1.5s', '-0.9s', '0.37'],
|
||||
['82%', '11px', '1.95s', '-1.55s', '0.2'],
|
||||
['89%', '17px', '1.45s', '-0.15s', '0.31'],
|
||||
['96%', '13px', '1.7s', '-1.05s', '0.24'],
|
||||
].map(([left, height, duration, delay, opacity]) => ({
|
||||
'--rain-delay': delay,
|
||||
'--rain-duration': duration,
|
||||
'--rain-height': height,
|
||||
'--rain-left': left,
|
||||
'--rain-opacity': opacity,
|
||||
}))
|
||||
const cardColors = {
|
||||
bgIos: 'bg-transparent',
|
||||
textIos: 'text-white',
|
||||
@@ -37,6 +70,12 @@ function formatHour(timestamp: number, index: number): string {
|
||||
timeZone: 'UTC',
|
||||
}).format(timestamp)
|
||||
}
|
||||
|
||||
function cameraErrorLabel(): string {
|
||||
return phone.t(
|
||||
`Apps.weather.webcam.errors.${weather.cameraError ?? 'request_failed'}`,
|
||||
)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -47,6 +86,9 @@ function formatHour(timestamp: number, index: number): string {
|
||||
:colors="{ bgIos: 'bg-transparent' }"
|
||||
>
|
||||
<div class="weather-app__backdrop" aria-hidden="true"></div>
|
||||
<div v-if="rainy" class="weather-app__rain" aria-hidden="true">
|
||||
<i v-for="(drop, index) in rainDrops" :key="index" :style="drop"></i>
|
||||
</div>
|
||||
<k-navbar class="weather-navbar" :title="phone.t('Apps.weather.name')">
|
||||
<template #after>
|
||||
<k-link
|
||||
@@ -126,6 +168,46 @@ function formatHour(timestamp: number, index: number): string {
|
||||
</k-card>
|
||||
</section>
|
||||
|
||||
<k-card
|
||||
v-if="selectedCamera"
|
||||
:colors="cardColors"
|
||||
:content-wrap="false"
|
||||
class="weather-panel weather-camera-card"
|
||||
>
|
||||
<div class="weather-camera-preview" :class="`weather-camera-preview--${forecast.condition}`">
|
||||
<div class="weather-camera-skyline" aria-hidden="true"></div>
|
||||
<div class="weather-camera-meta">
|
||||
<span class="weather-camera-live"><i></i>{{ phone.t('Apps.weather.webcam.live') }}</span>
|
||||
<span>{{ phone.t(`Apps.weather.webcam.locations.${selectedCamera.id}`) }}</span>
|
||||
</div>
|
||||
<div class="weather-camera-copy">
|
||||
<strong>{{ phone.t('Apps.weather.webcam.title') }}</strong>
|
||||
<span>{{ phone.t('Apps.weather.webcam.subtitle') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="weather-camera-action">
|
||||
<k-button
|
||||
large
|
||||
rounded
|
||||
:disabled="weather.isOpeningCamera"
|
||||
@click="weather.openCamera(selectedCamera.id)"
|
||||
>
|
||||
<Video :size="17" />
|
||||
{{ phone.t('Apps.weather.webcam.view') }}
|
||||
</k-button>
|
||||
<p v-if="weather.cameraError">{{ cameraErrorLabel() }}</p>
|
||||
</div>
|
||||
</k-card>
|
||||
|
||||
<k-card
|
||||
v-else-if="weather.camerasLoaded"
|
||||
:colors="cardColors"
|
||||
:content-wrap="false"
|
||||
class="weather-panel weather-camera-unavailable"
|
||||
>
|
||||
{{ phone.t('Apps.weather.webcam.unavailable') }}
|
||||
</k-card>
|
||||
|
||||
<k-card
|
||||
:colors="cardColors"
|
||||
:content-wrap="false"
|
||||
|
||||
@@ -4765,19 +4765,42 @@ app.post('/api/:endpoint', (request, response) => {
|
||||
return
|
||||
}
|
||||
if (endpoint === 'weather:get') {
|
||||
const rainyWeather = testScenario === 'weather-rain'
|
||||
response.json({
|
||||
success: true,
|
||||
data: {
|
||||
clock: { year: 2026, month: 8, day: 5, hour: 17, minute: 20 },
|
||||
condition: 'partly_cloudy',
|
||||
condition: rainyWeather ? 'rain' : 'partly_cloudy',
|
||||
nextCondition: 'rain',
|
||||
rainLevel: 0.08,
|
||||
rainLevel: rainyWeather ? 0.82 : 0.08,
|
||||
region: 'los_santos',
|
||||
windSpeed: 3.2,
|
||||
windSpeed: rainyWeather ? 0 : 3.2,
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
if (endpoint === 'weather:cameras') {
|
||||
response.json({
|
||||
success: true,
|
||||
data: {
|
||||
cameras: [
|
||||
{ id: 'legion_square', region: 'los_santos' },
|
||||
{ id: 'sandy_shores', region: 'blaine_county' },
|
||||
{ id: 'cayo_airstrip', region: 'cayo_perico' },
|
||||
],
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
if (endpoint === 'weather:camera-open') {
|
||||
const ids = ['legion_square', 'sandy_shores', 'cayo_airstrip']
|
||||
response.json(
|
||||
ids.includes(request.body?.id)
|
||||
? { success: true }
|
||||
: { success: false, error: 'camera_not_found' },
|
||||
)
|
||||
return
|
||||
}
|
||||
if (endpoint === 'map:getPlayerCoords') {
|
||||
response.json({
|
||||
success: true,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
Locales["en"] = {
|
||||
CommandDescription = "Open your phone.",
|
||||
WeatherCameraExit = "Weather camera | Press BACKSPACE to return",
|
||||
FlipTokCommand = {
|
||||
usage = "Usage: /{command} <@handle> [on|off]",
|
||||
noPermission = "You do not have permission to manage FlipTok verification.",
|
||||
@@ -947,6 +948,17 @@ Locales["en"] = {
|
||||
feelsLike = "Feels Like", wind = "Wind", humidity = "Humidity", rain = "Precipitation",
|
||||
hourly = "Next Hours", unavailable = "Weather is unavailable",
|
||||
stale = "Showing the last available weather update.", tryAgain = "Try Again",
|
||||
webcam = {
|
||||
title = "Weather Cameras", live = "LIVE", view = "View Live Camera",
|
||||
subtitle = "A live look at current conditions", unavailable = "No weather cameras are available.",
|
||||
locations = { legion_square = "Legion Square", sandy_shores = "Sandy Shores", cayo_airstrip = "Cayo Perico Airstrip" },
|
||||
errors = {
|
||||
camera_disabled = "Weather cameras are currently disabled.", camera_not_found = "This weather camera is no longer available.",
|
||||
camera_requires_safe_position = "Stand still and exit your vehicle before opening a camera.",
|
||||
camera_active = "A weather camera is already open.", rate_limited = "Too many camera requests. Try again shortly.",
|
||||
request_failed = "The weather camera could not be opened.", not_authenticated = "Unlock the phone before opening a camera.",
|
||||
},
|
||||
},
|
||||
regions = { los_santos = "Los Santos", blaine_county = "Blaine County", cayo_perico = "Cayo Perico" },
|
||||
conditions = {
|
||||
sunny = "Sunny", clear = "Clear", partly_cloudy = "Partly Cloudy", cloudy = "Cloudy",
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
Config.WeatherCameras = {
|
||||
Enabled = true,
|
||||
RequestsPerMinute = 10,
|
||||
SessionSeconds = 30,
|
||||
UseStreamingFocus = true,
|
||||
Cameras = {
|
||||
{
|
||||
Id = "legion_square",
|
||||
Region = "los_santos",
|
||||
Coords = { x = -236.72, y = -1001.42, z = 45.20 },
|
||||
Rotation = { x = -16.0, y = 0.0, z = -28.0 },
|
||||
FieldOfView = 52.0,
|
||||
},
|
||||
{
|
||||
Id = "sandy_shores",
|
||||
Region = "blaine_county",
|
||||
Coords = { x = 1712.64, y = 3654.86, z = 47.25 },
|
||||
Rotation = { x = -13.0, y = 0.0, z = 142.0 },
|
||||
FieldOfView = 55.0,
|
||||
},
|
||||
{
|
||||
Id = "cayo_airstrip",
|
||||
Region = "cayo_perico",
|
||||
Coords = { x = 4437.38, y = -4471.62, z = 24.42 },
|
||||
Rotation = { x = -10.0, y = 0.0, z = 45.0 },
|
||||
FieldOfView = 58.0,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
-- Remote cameras never move the player or alter spectator/entity state. Streaming focus is
|
||||
-- local and is cleared when the camera closes. Disable it if an anti-cheat policy forbids it.
|
||||
@@ -32,6 +32,7 @@ client_scripts {
|
||||
'source/client/skyride.lua',
|
||||
'source/client/housing.lua',
|
||||
'source/client/crewlink.lua',
|
||||
'source/client/weather_camera.lua',
|
||||
'source/bridge/client/radio.lua',
|
||||
'source/client/payphones.lua',
|
||||
'source/client/main.lua',
|
||||
@@ -41,6 +42,7 @@ client_scripts {
|
||||
server_scripts {
|
||||
'@oxmysql/lib/MySQL.lua',
|
||||
'config/config.lua',
|
||||
'config/weather_cameras.lua',
|
||||
'config/media.lua',
|
||||
'config/music.lua',
|
||||
'config/locales/*.lua',
|
||||
@@ -55,6 +57,7 @@ server_scripts {
|
||||
'source/bridge/server/inventory/*.lua',
|
||||
'source/server/db_migrate.lua',
|
||||
'source/server/phone.lua',
|
||||
'source/server/weather.lua',
|
||||
'source/server/sim.lua',
|
||||
'source/server/calls.lua',
|
||||
'source/server/media.lua',
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
local active_camera = nil
|
||||
local focus_active = false
|
||||
local camera_session = 0
|
||||
local transition_ms = 350
|
||||
|
||||
local function stop_weather_camera(resume_phone)
|
||||
camera_session = camera_session + 1
|
||||
if active_camera and DoesCamExist(active_camera) then
|
||||
RenderScriptCams(false, true, transition_ms, true, false)
|
||||
DestroyCam(active_camera, false)
|
||||
end
|
||||
active_camera = nil
|
||||
if focus_active then ClearFocus() end
|
||||
focus_active = false
|
||||
if resume_phone then
|
||||
SetNuiFocus(true, true)
|
||||
SendNUIMessage({ type = "app:resume" })
|
||||
end
|
||||
end
|
||||
|
||||
local function valid_vector(value)
|
||||
return type(value) == "table"
|
||||
and type(value.x) == "number" and value.x == value.x
|
||||
and type(value.y) == "number" and value.y == value.y
|
||||
and type(value.z) == "number" and value.z == value.z
|
||||
end
|
||||
|
||||
local function start_weather_camera(camera)
|
||||
if type(camera) ~= "table" or not valid_vector(camera.coords) or not valid_vector(camera.rotation) then
|
||||
return false
|
||||
end
|
||||
local fov = tonumber(camera.fieldOfView)
|
||||
local duration = tonumber(camera.sessionSeconds)
|
||||
if not fov or fov < 20.0 or fov > 90.0 or not duration then return false end
|
||||
|
||||
camera_session = camera_session + 1
|
||||
local session = camera_session
|
||||
SetNuiFocus(false, false)
|
||||
SendNUIMessage({ type = "app:suspend" })
|
||||
active_camera = CreateCamWithParams(
|
||||
"DEFAULT_SCRIPTED_CAMERA",
|
||||
camera.coords.x, camera.coords.y, camera.coords.z,
|
||||
camera.rotation.x, camera.rotation.y, camera.rotation.z,
|
||||
fov, true, 2
|
||||
)
|
||||
if not active_camera or not DoesCamExist(active_camera) then
|
||||
stop_weather_camera(true)
|
||||
return false
|
||||
end
|
||||
if camera.useStreamingFocus == true then
|
||||
SetFocusPosAndVel(camera.coords.x, camera.coords.y, camera.coords.z, 0.0, 0.0, 0.0)
|
||||
focus_active = true
|
||||
end
|
||||
SetCamActive(active_camera, true)
|
||||
RenderScriptCams(true, true, transition_ms, true, false)
|
||||
|
||||
CreateThread(function()
|
||||
local expires_at = GetGameTimer() + math.floor(duration * 1000)
|
||||
while active_camera and session == camera_session do
|
||||
Wait(0)
|
||||
DisableAllControlActions(0)
|
||||
EnableControlAction(0, 177, true)
|
||||
BeginTextCommandDisplayHelp("STRING")
|
||||
AddTextComponentSubstringPlayerName(locale.WeatherCameraExit or "Weather camera | BACKSPACE to return")
|
||||
EndTextCommandDisplayHelp(0, false, true, -1)
|
||||
if IsControlJustReleased(0, 177) or IsEntityDead(PlayerPedId()) or GetGameTimer() >= expires_at then
|
||||
stop_weather_camera(true)
|
||||
break
|
||||
end
|
||||
end
|
||||
end)
|
||||
return true
|
||||
end
|
||||
|
||||
RegisterNUICallback("weather:cameras", function(_, cb)
|
||||
local response = Bridge.Callbacks.Trigger("sky_phone:weather:cameras", {})
|
||||
cb(response or { success = false, error = "request_failed" })
|
||||
end)
|
||||
|
||||
RegisterNUICallback("weather:camera-open", function(data, cb)
|
||||
if active_camera then cb({ success = false, error = "camera_active" }) return end
|
||||
local id = type(data) == "table" and data.id or nil
|
||||
if type(id) ~= "string" or not id:match("^[a-z0-9_]+$") then
|
||||
cb({ success = false, error = "camera_not_found" })
|
||||
return
|
||||
end
|
||||
local ped = PlayerPedId()
|
||||
if IsEntityDead(ped) or IsPedInAnyVehicle(ped, false) or GetEntitySpeed(ped) > 1.0 then
|
||||
cb({ success = false, error = "camera_requires_safe_position" })
|
||||
return
|
||||
end
|
||||
local response = Bridge.Callbacks.Trigger("sky_phone:weather:camera-open", { id = id })
|
||||
if not response or response.success ~= true or not start_weather_camera(response.data) then
|
||||
cb(response or { success = false, error = "request_failed" })
|
||||
return
|
||||
end
|
||||
cb({ success = true })
|
||||
end)
|
||||
|
||||
AddEventHandler("sky_phone:nuiClosed", function() stop_weather_camera(false) end)
|
||||
AddEventHandler("onResourceStop", function(resource)
|
||||
if resource == GetCurrentResourceName() then stop_weather_camera(false) end
|
||||
end)
|
||||
@@ -0,0 +1,62 @@
|
||||
local camera_config = Config.WeatherCameras or {}
|
||||
local cameras_by_id = {}
|
||||
local camera_summaries = {}
|
||||
|
||||
local function finite_number(value)
|
||||
return type(value) == "number" and value == value and value > -math.huge and value < math.huge
|
||||
end
|
||||
|
||||
for _, camera in ipairs(camera_config.Cameras or {}) do
|
||||
local id = type(camera.Id) == "string" and camera.Id or ""
|
||||
local coords = camera.Coords or {}
|
||||
local rotation = camera.Rotation or {}
|
||||
if id:match("^[a-z0-9_]+$")
|
||||
and type(camera.Region) == "string"
|
||||
and finite_number(coords.x) and finite_number(coords.y) and finite_number(coords.z)
|
||||
and finite_number(rotation.x) and finite_number(rotation.y) and finite_number(rotation.z)
|
||||
then
|
||||
cameras_by_id[id] = camera
|
||||
camera_summaries[#camera_summaries + 1] = { id = id, region = camera.Region }
|
||||
else
|
||||
print(("[sky_phone] Ignoring invalid weather camera configuration: %s"):format(tostring(camera.Id)))
|
||||
end
|
||||
end
|
||||
|
||||
local function authorize(source)
|
||||
local session = SkyPhone.RequireSession(source)
|
||||
if not session then return false, "not_authenticated" end
|
||||
if camera_config.Enabled == false then return false, "camera_disabled" end
|
||||
local limit = math.max(1, math.floor(tonumber(camera_config.RequestsPerMinute) or 10))
|
||||
if not SkyPhone.AllowOperation(source, "weather_camera", limit, 60) then
|
||||
return false, "rate_limited"
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
Bridge.Callbacks.Register("sky_phone:weather:cameras", function(source)
|
||||
local allowed, error_code = authorize(source)
|
||||
if not allowed then return { success = false, error = error_code } end
|
||||
return { success = true, data = { cameras = camera_summaries } }
|
||||
end)
|
||||
|
||||
Bridge.Callbacks.Register("sky_phone:weather:camera-open", function(source, data)
|
||||
local allowed, error_code = authorize(source)
|
||||
if not allowed then return { success = false, error = error_code } end
|
||||
local id = type(data) == "table" and data.id or nil
|
||||
local camera = type(id) == "string" and cameras_by_id[id] or nil
|
||||
if not camera then return { success = false, error = "camera_not_found" } end
|
||||
|
||||
local fov = math.max(20.0, math.min(90.0, tonumber(camera.FieldOfView) or 50.0))
|
||||
local duration = math.max(10, math.min(120, math.floor(tonumber(camera_config.SessionSeconds) or 30)))
|
||||
return {
|
||||
success = true,
|
||||
data = {
|
||||
id = id,
|
||||
coords = camera.Coords,
|
||||
rotation = camera.Rotation,
|
||||
fieldOfView = fov,
|
||||
sessionSeconds = duration,
|
||||
useStreamingFocus = camera_config.UseStreamingFocus == true,
|
||||
},
|
||||
}
|
||||
end)
|
||||
Reference in New Issue
Block a user