diff --git a/README.md b/README.md index 15ba3db..0930eba 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,25 @@ # sky_phone +## FlipTok verification + +FlipTok verification is server-authoritative and limited to the framework groups configured in +`Config.FlipTok.AdminGroups`; no command ACE is required. Use +`/fliptokverify <@handle> [on|off]`. Without `on` or `off`, the current blue-check state is toggled. +The command name is configurable through `Config.FlipTok.VerifyCommand`. +Verification command access uses `Config.FlipTok.AdminGroups`. The report moderation overview is +server-authoritative and independently restricted through `Config.FlipTok.ReportAdminGroups`. + +## FlipTok music + +Licensed music can be exposed in the composer through `Config.FlipTok.MusicTracks`. Keep the IDs +stable because published videos store the selected ID; URLs must be directly playable by the NUI. + +```lua +MusicTracks = { + { Id = "night-drive", Title = "Night Drive", Artist = "Sky Radio", Url = "https://cdn.example.com/night-drive.ogg" }, +} +``` + Standalone FiveM phone built with Vue 3, TypeScript, Pinia, Vue Router, Konsta UI 5, and Tailwind CSS 4. Each non-stackable `phone` item receives a unique 15-digit IMEI and owns its server-persisted device state. The phone opens through the usable item; `/phone` is disabled unless `Config.Phone.DevelopmentCommand` is enabled explicitly. An iFruit account is optional. Unlinked devices retain local settings, alarms, media, apps, notes, contacts, and recent calls. Linking from Mail or Settings moves local data into an empty cloud account; an existing cloud dataset wins over local contacts and recents. Signing out keeps an editable local snapshot without deleting cloud data. diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 4240fb8..20121d0 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -31,6 +31,7 @@ import { useAccountStore } from '@/stores/account' import { useMailStore } from '@/stores/mail' import { useMessagesStore } from '@/stores/messages' import { useDarkChatStore } from '@/stores/darkchat' +import { useFlipTokStore } from '@/stores/fliptok' import { useMediaStore } from '@/stores/media' import { useMarketplaceStore } from '@/stores/marketplace' import { useAppStoreStore } from '@/stores/app-store' @@ -60,6 +61,8 @@ type AppMessage = { | MarketplaceEventData | MessagesEventData | DarkChatEventData + | FlipTokVerificationData + | FlipTokNotificationData | PhoneCall | PhoneNotificationInput | PhoneOpenPayload @@ -115,6 +118,20 @@ type CalendarReminderData = { text?: string title?: string } + +type FlipTokVerificationData = { + profileId: number + verified: boolean +} + +type FlipTokNotificationData = { + actor?: string + device?: PhoneNotificationDevicePayload + kind?: 'like' | 'comment' | 'follow' | 'verified' + text?: string + title?: string + videoId?: string +} const REFERENCE_VIEWPORT_WIDTH = 1920 const REFERENCE_VIEWPORT_HEIGHT = 1080 const PHONE_BASE_SCALE = 0.69 @@ -130,6 +147,7 @@ const banking = useBankingStore() const mail = useMailStore() const messages = useMessagesStore() const darkchat = useDarkChatStore() +const fliptok = useFlipTokStore() const media = useMediaStore() const marketplace = useMarketplaceStore() const appStore = useAppStoreStore() @@ -277,6 +295,32 @@ function onMessage(event: MessageEvent): void { } else if (event.data?.type === 'marketplace:changed' && event.data.data) { const data = event.data.data as MarketplaceEventData if (data.counts) marketplace.setCounts(data.counts) + } else if ( + event.data?.type === 'fliptok:verification-changed' && + event.data.data + ) { + const data = event.data.data as FlipTokVerificationData + fliptok.applyVerification(Number(data.profileId), data.verified === true) + } else if (event.data?.type === 'fliptok:new' && event.data.data) { + const data = event.data.data as FlipTokNotificationData + const notification: PhoneNotificationInput = { + appId: 'fliptok', + subtitle: data.actor, + text: data.text ?? phone.t('Apps.fliptok.notifications.default'), + title: data.title ?? phone.t('Apps.fliptok.name'), + } + if ( + data.device && + (!phone.isOpen || data.device.imei !== phone.device?.imei) + ) { + notification.device = { + imei: data.device.imei, + name: data.device.name, + preferences: parsePhonePreferences(data.device.settings ?? null), + } + } + notifications.show(notification) + if (phone.isOpen) void fliptok.loadActivities() } else if ( event.data?.type === 'marketplace:new-message' && event.data.data diff --git a/frontend/src/assets/img/app-icons/fliptok.svg b/frontend/src/assets/img/app-icons/fliptok.svg new file mode 100644 index 0000000..9cc1d98 --- /dev/null +++ b/frontend/src/assets/img/app-icons/fliptok.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/frontend/src/assets/img/frames/red.webp b/frontend/src/assets/img/frames/red.webp new file mode 100644 index 0000000..7b37070 Binary files /dev/null and b/frontend/src/assets/img/frames/red.webp differ diff --git a/frontend/src/assets/main.css b/frontend/src/assets/main.css index 1bd1679..ed8945a 100644 --- a/frontend/src/assets/main.css +++ b/frontend/src/assets/main.css @@ -364,14 +364,16 @@ button { top: 0; left: 0; width: 100%; - height: 45px; - padding: 19px 42px 0; + height: 48px; + padding: 17px 30px 0; color: #fff; display: flex; justify-content: space-between; align-items: flex-start; - font-size: 12px; - font-weight: 500; + font-size: 17px; + font-weight: 600; + line-height: 20px; + letter-spacing: -0.25px; text-shadow: 0 1px 3px #0009; pointer-events: none; } @@ -382,10 +384,10 @@ button { display: flex; align-items: center; justify-content: flex-end; - gap: 3px; - width: 118px; - height: 45px; - padding: 11px 42px 0 0; + gap: 6px; + width: 120px; + height: 48px; + padding: 17px 30px 0 0; border: 0; color: inherit; background: transparent; diff --git a/frontend/src/components/PhoneMediaCapture.vue b/frontend/src/components/PhoneMediaCapture.vue index cbf6c2c..cf6f48d 100644 --- a/frontend/src/components/PhoneMediaCapture.vue +++ b/frontend/src/components/PhoneMediaCapture.vue @@ -23,6 +23,7 @@ let renderFrameId: number | undefined let lastRenderAt = 0 let recorder: MediaRecorder | null = null let stream: MediaStream | null = null +let microphoneStream: MediaStream | null = null let chunks: RecordingChunk[] = [] let lastChunkAt = 0 let lastChunkTimecode: number | null = null @@ -95,7 +96,9 @@ function resetRecording(): void { function stopTracks(): void { stream?.getTracks().forEach((track) => track.stop()) + microphoneStream?.getTracks().forEach((track) => track.stop()) stream = null + microphoneStream = null } function cleanupRecording(): void { @@ -109,7 +112,7 @@ function cleanupRecording(): void { postRecordState(false) } -function startRecording(data: Record): void { +async function startRecording(data: Record): Promise { if (recorder) return if (typeof MediaRecorder === 'undefined') { window.postMessage( @@ -127,13 +130,45 @@ function startRecording(data: Record): void { } startRenderLoop() resetRecording() - stream = canvasRef.value?.captureStream(captureFps) ?? null - if (!stream) { + const videoStream = canvasRef.value?.captureStream(captureFps) ?? null + if (!videoStream) { cleanupRecording() return } + if (data.microphoneEnabled === true) { + try { + microphoneStream = await navigator.mediaDevices.getUserMedia({ + audio: { + autoGainControl: true, + echoCancellation: true, + noiseSuppression: true, + }, + }) + } catch { + videoStream.getTracks().forEach((track) => track.stop()) + cleanupRecording() + window.postMessage( + { + data: { error: 'microphone_unavailable', success: false }, + type: 'camera:recordError', + }, + '*', + ) + return + } + } + stream = new MediaStream([ + ...videoStream.getVideoTracks(), + ...(microphoneStream?.getAudioTracks() ?? []), + ]) + const mimeType = [ + 'video/webm;codecs=vp8,opus', + 'video/webm;codecs=vp8', + 'video/webm', + ].find((type) => MediaRecorder.isTypeSupported(type)) recorder = new MediaRecorder(stream, { - mimeType: 'video/webm', + ...(mimeType ? { mimeType } : {}), + audioBitsPerSecond: 128_000, videoBitsPerSecond: bitrateBps, }) recorder.ondataavailable = (event) => { @@ -311,7 +346,7 @@ function onMessage(event: MessageEvent): void { type?: string } if (message.type === 'camera:recordStart') { - startRecording(message.data ?? {}) + void startRecording(message.data ?? {}) } else if (message.type === 'camera:recordStop') { void stopRecording(message.data ?? {}) } else if (message.type === 'camera:recordCancel') { diff --git a/frontend/src/components/PhoneStatusBar.vue b/frontend/src/components/PhoneStatusBar.vue index 74ae40d..73ff7d1 100644 --- a/frontend/src/components/PhoneStatusBar.vue +++ b/frontend/src/components/PhoneStatusBar.vue @@ -40,13 +40,13 @@ onBeforeUnmount(() => { >