mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-09-04 17:23:25 +00:00
Compare commits
18
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fe38dc6efa | ||
|
|
10afed3d2c | ||
|
|
37948e1f6d | ||
|
|
9574c0ca58 | ||
|
|
d32e6b5584 | ||
|
|
7bbafd0026 | ||
|
|
300329825f | ||
|
|
93d2263f14 | ||
|
|
dca0868753 | ||
|
|
36b859cabb | ||
|
|
d0317a35c0 | ||
|
|
074117fe33 | ||
|
|
a1025f4520 | ||
|
|
7b8bab75a8 | ||
|
|
c0492a70cb | ||
|
|
05169acbfe | ||
|
|
086b77e464 | ||
|
|
de613e2502 |
@@ -26,6 +26,100 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|||||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
SOFTWARE.
|
SOFTWARE.
|
||||||
|
|
||||||
|
## Inter 4.1
|
||||||
|
|
||||||
|
Copyright (c) 2016 The Inter Project Authors (https://github.com/rsms/inter)
|
||||||
|
|
||||||
|
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||||
|
This license is copied below, and is also available with a FAQ at:
|
||||||
|
http://scripts.sil.org/OFL
|
||||||
|
|
||||||
|
-----------------------------------------------------------
|
||||||
|
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||||
|
-----------------------------------------------------------
|
||||||
|
|
||||||
|
PREAMBLE
|
||||||
|
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||||
|
development of collaborative font projects, to support the font creation
|
||||||
|
efforts of academic and linguistic communities, and to provide a free and
|
||||||
|
open framework in which fonts may be shared and improved in partnership
|
||||||
|
with others.
|
||||||
|
|
||||||
|
The OFL allows the licensed fonts to be used, studied, modified and
|
||||||
|
redistributed freely as long as they are not sold by themselves. The
|
||||||
|
fonts, including any derivative works, can be bundled, embedded,
|
||||||
|
redistributed and/or sold with any software provided that any reserved
|
||||||
|
names are not used by derivative works. The fonts and derivatives,
|
||||||
|
however, cannot be released under any other type of license. The
|
||||||
|
requirement for fonts to remain under this license does not apply
|
||||||
|
to any document created using the fonts or their derivatives.
|
||||||
|
|
||||||
|
DEFINITIONS
|
||||||
|
"Font Software" refers to the set of files released by the Copyright
|
||||||
|
Holder(s) under this license and clearly marked as such. This may
|
||||||
|
include source files, build scripts and documentation.
|
||||||
|
|
||||||
|
"Reserved Font Name" refers to any names specified as such after the
|
||||||
|
copyright statement(s).
|
||||||
|
|
||||||
|
"Original Version" refers to the collection of Font Software components as
|
||||||
|
distributed by the Copyright Holder(s).
|
||||||
|
|
||||||
|
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||||
|
or substituting -- in part or in whole -- any of the components of the
|
||||||
|
Original Version, by changing formats or by porting the Font Software to a
|
||||||
|
new environment.
|
||||||
|
|
||||||
|
"Author" refers to any designer, engineer, programmer, technical
|
||||||
|
writer or other person who contributed to the Font Software.
|
||||||
|
|
||||||
|
PERMISSION AND CONDITIONS
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining
|
||||||
|
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||||
|
redistribute, and sell modified and unmodified copies of the Font
|
||||||
|
Software, subject to the following conditions:
|
||||||
|
|
||||||
|
1) Neither the Font Software nor any of its individual components,
|
||||||
|
in Original or Modified Versions, may be sold by itself.
|
||||||
|
|
||||||
|
2) Original or Modified Versions of the Font Software may be bundled,
|
||||||
|
redistributed and/or sold with any software, provided that each copy
|
||||||
|
contains the above copyright notice and this license. These can be
|
||||||
|
included either as stand-alone text files, human-readable headers or
|
||||||
|
in the appropriate machine-readable metadata fields within text or
|
||||||
|
binary files as long as those fields can be easily viewed by the user.
|
||||||
|
|
||||||
|
3) No Modified Version of the Font Software may use the Reserved Font
|
||||||
|
Name(s) unless explicit written permission is granted by the corresponding
|
||||||
|
Copyright Holder. This restriction only applies to the primary font name as
|
||||||
|
presented to the users.
|
||||||
|
|
||||||
|
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||||
|
Software shall not be used to promote, endorse or advertise any
|
||||||
|
Modified Version, except to acknowledge the contribution(s) of the
|
||||||
|
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||||
|
permission.
|
||||||
|
|
||||||
|
5) The Font Software, modified or unmodified, in part or in whole,
|
||||||
|
must be distributed entirely under this license, and must not be
|
||||||
|
distributed under any other license. The requirement for fonts to
|
||||||
|
remain under this license does not apply to any document created
|
||||||
|
using the Font Software.
|
||||||
|
|
||||||
|
TERMINATION
|
||||||
|
This license becomes null and void if any of the above conditions are not met.
|
||||||
|
|
||||||
|
DISCLAIMER
|
||||||
|
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||||
|
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||||
|
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||||
|
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||||
|
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||||
|
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||||
|
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER
|
||||||
|
DEALINGS IN THE FONT SOFTWARE.
|
||||||
|
|
||||||
## Framework7 documentation placeholder media
|
## Framework7 documentation placeholder media
|
||||||
|
|
||||||
The development-only Sky UI Kitchen Sink includes placeholder images mirrored
|
The development-only Sky UI Kitchen Sink includes placeholder images mirrored
|
||||||
|
|||||||
+78
-6
@@ -56,6 +56,7 @@ import { useNotesStore } from '@/stores/notes'
|
|||||||
import { useMemosStore } from '@/stores/memos'
|
import { useMemosStore } from '@/stores/memos'
|
||||||
import { useWeatherStore } from '@/stores/weather'
|
import { useWeatherStore } from '@/stores/weather'
|
||||||
import { useEasyShareStore } from '@/stores/easyshare'
|
import { useEasyShareStore } from '@/stores/easyshare'
|
||||||
|
import { useRadioStore } from '@/stores/radio'
|
||||||
import {
|
import {
|
||||||
useNotificationsStore,
|
useNotificationsStore,
|
||||||
type PhoneNotification,
|
type PhoneNotification,
|
||||||
@@ -70,10 +71,15 @@ import type {
|
|||||||
CompanyUnreadCounts,
|
CompanyUnreadCounts,
|
||||||
} from '@/types/companies'
|
} from '@/types/companies'
|
||||||
import type { PhoneCall } from '@/types/phone'
|
import type { PhoneCall } from '@/types/phone'
|
||||||
|
import type { DynamicIslandActivity } from '@/types/dynamicIsland'
|
||||||
import type { EasyShareEvent } from '@/types/easyshare'
|
import type { EasyShareEvent } from '@/types/easyshare'
|
||||||
import type { CryptoMarketChangedData } from '@/types/crypto'
|
import type { CryptoMarketChangedData } from '@/types/crypto'
|
||||||
import type { CityWarnEventData } from '@/types/citywarn'
|
import type { CityWarnEventData } from '@/types/citywarn'
|
||||||
import { nuiCall } from '@/utils/nui'
|
import { nuiCall } from '@/utils/nui'
|
||||||
|
import {
|
||||||
|
installPhoneAudioController,
|
||||||
|
setPhoneOutputVolume,
|
||||||
|
} from '@/utils/phoneAudio'
|
||||||
import { formatTimer } from '@/utils/clock'
|
import { formatTimer } from '@/utils/clock'
|
||||||
import { parsePhonePreferences } from '@/utils/preferences'
|
import { parsePhonePreferences } from '@/utils/preferences'
|
||||||
import { getHairlinePixelStyle } from '@/utils/rendering'
|
import { getHairlinePixelStyle } from '@/utils/rendering'
|
||||||
@@ -82,6 +88,7 @@ import { isTrustedRootMessageSource } from '@/utils/windowMessages'
|
|||||||
import SpringboardView from '@/views/SpringboardView.vue'
|
import SpringboardView from '@/views/SpringboardView.vue'
|
||||||
|
|
||||||
type AppMessage = {
|
type AppMessage = {
|
||||||
|
openHome?: boolean
|
||||||
type?: string
|
type?: string
|
||||||
data?:
|
data?:
|
||||||
| CalendarReminderData
|
| CalendarReminderData
|
||||||
@@ -313,6 +320,7 @@ const notes = useNotesStore()
|
|||||||
const memos = useMemosStore()
|
const memos = useMemosStore()
|
||||||
const weather = useWeatherStore()
|
const weather = useWeatherStore()
|
||||||
const easyShare = useEasyShareStore()
|
const easyShare = useEasyShareStore()
|
||||||
|
const radio = useRadioStore()
|
||||||
const notifications = useNotificationsStore()
|
const notifications = useNotificationsStore()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -335,8 +343,13 @@ const DARK_STATUS_BAR_APP_IDS = new Set([
|
|||||||
'minesweeper',
|
'minesweeper',
|
||||||
'number-merge',
|
'number-merge',
|
||||||
])
|
])
|
||||||
|
const isDynamicIslandGalleryRoute = computed(
|
||||||
|
() => isDevelopment && route.name === 'development-dynamic-islands',
|
||||||
|
)
|
||||||
const isDevelopmentRoute = computed(
|
const isDevelopmentRoute = computed(
|
||||||
() => isDevelopment && route.name === 'development-sky-ui',
|
() =>
|
||||||
|
isDevelopment &&
|
||||||
|
(route.name === 'development-sky-ui' || isDynamicIslandGalleryRoute.value),
|
||||||
)
|
)
|
||||||
const appTransitionName = computed(() =>
|
const appTransitionName = computed(() =>
|
||||||
route.query.transition === 'app-switch' ? 'app-switch' : 'app-window',
|
route.query.transition === 'app-switch' ? 'app-switch' : 'app-window',
|
||||||
@@ -356,9 +369,12 @@ const setupPreviewDismissed = ref(false)
|
|||||||
const setupDevelopmentSkipped = ref(false)
|
const setupDevelopmentSkipped = ref(false)
|
||||||
const setupAppearanceSelected = ref(false)
|
const setupAppearanceSelected = ref(false)
|
||||||
const pendingUnlockRoute = ref<string | null>(null)
|
const pendingUnlockRoute = ref<string | null>(null)
|
||||||
|
const openHomeRequested = ref(false)
|
||||||
const unlockedServicesLoaded = ref(false)
|
const unlockedServicesLoaded = ref(false)
|
||||||
const controlCenterOpened = ref(false)
|
const controlCenterOpened = ref(false)
|
||||||
const activitySuspended = ref(false)
|
const activitySuspended = ref(false)
|
||||||
|
const dynamicIslandExpanded = ref(false)
|
||||||
|
const dynamicIslandActivity = ref<DynamicIslandActivity | null>(null)
|
||||||
const simPicker = ref<SimPickerPayload | null>(null)
|
const simPicker = ref<SimPickerPayload | null>(null)
|
||||||
const setupRequired = computed(
|
const setupRequired = computed(
|
||||||
() =>
|
() =>
|
||||||
@@ -432,6 +448,12 @@ const phoneResolutionStyle = computed<CSSProperties>(() => ({
|
|||||||
}))
|
}))
|
||||||
const phoneStageStyle = computed<CSSProperties>(() => ({
|
const phoneStageStyle = computed<CSSProperties>(() => ({
|
||||||
...phoneResolutionStyle.value,
|
...phoneResolutionStyle.value,
|
||||||
|
'--phone-live-activity-peek-height':
|
||||||
|
dynamicIslandActivity.value === 'music'
|
||||||
|
? '190px'
|
||||||
|
: dynamicIslandActivity.value === 'recording'
|
||||||
|
? '132px'
|
||||||
|
: '112px',
|
||||||
visibility: activitySuspended.value ? 'hidden' : 'visible',
|
visibility: activitySuspended.value ? 'hidden' : 'visible',
|
||||||
}))
|
}))
|
||||||
const phoneDisplayStyle = computed<CSSProperties>(() => ({
|
const phoneDisplayStyle = computed<CSSProperties>(() => ({
|
||||||
@@ -449,6 +471,7 @@ let unlockTimer: number | undefined
|
|||||||
let passcodeLockTimer: number | undefined
|
let passcodeLockTimer: number | undefined
|
||||||
let hardwareVolumeHudTimer: number | undefined
|
let hardwareVolumeHudTimer: number | undefined
|
||||||
let unlockedServicesIdle: number | undefined
|
let unlockedServicesIdle: number | undefined
|
||||||
|
let removePhoneAudioController: (() => void) | undefined
|
||||||
let phoneClosePending = false
|
let phoneClosePending = false
|
||||||
let simPickerClosePending = false
|
let simPickerClosePending = false
|
||||||
|
|
||||||
@@ -758,6 +781,7 @@ function onMessage(event: MessageEvent<AppMessage>): void {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
} else if (event.data?.type === 'app:open') {
|
} else if (event.data?.type === 'app:open') {
|
||||||
|
openHomeRequested.value = event.data.openHome === true
|
||||||
hydratePhone(event.data.data as PhoneOpenPayload)
|
hydratePhone(event.data.data as PhoneOpenPayload)
|
||||||
void syncNavigationState().then(() => nuiCall('ui:opened'))
|
void syncNavigationState().then(() => nuiCall('ui:opened'))
|
||||||
} else if (event.data?.type === 'device:updated') {
|
} else if (event.data?.type === 'device:updated') {
|
||||||
@@ -1473,6 +1497,7 @@ function onFocusOut(event: FocusEvent): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
|
removePhoneAudioController = installPhoneAudioController()
|
||||||
document.addEventListener('focusin', onFocusIn)
|
document.addEventListener('focusin', onFocusIn)
|
||||||
document.addEventListener('focusout', onFocusOut)
|
document.addEventListener('focusout', onFocusOut)
|
||||||
window.addEventListener('message', onMessage)
|
window.addEventListener('message', onMessage)
|
||||||
@@ -1587,6 +1612,23 @@ watch(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => Boolean(dynamicIslandActivity.value || calls.activeCall),
|
||||||
|
(active) => {
|
||||||
|
void nuiCall('ui:live-activity', { active })
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
hardwareAlertVolume,
|
||||||
|
(volume) => {
|
||||||
|
setPhoneOutputVolume(volume / 100)
|
||||||
|
if (radio.data.connected) void radio.setVolume(volume)
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => phone.isOpen,
|
() => phone.isOpen,
|
||||||
(isOpen) => {
|
(isOpen) => {
|
||||||
@@ -1619,7 +1661,8 @@ watch(
|
|||||||
}
|
}
|
||||||
isLocked.value = setupRequired.value
|
isLocked.value = setupRequired.value
|
||||||
? false
|
? false
|
||||||
: !isDevelopment || developmentLockScreenPreview
|
: developmentLockScreenPreview ||
|
||||||
|
(!isDevelopment && phone.security.enabled)
|
||||||
passcodeRequired.value = isLocked.value && phone.security.enabled
|
passcodeRequired.value = isLocked.value && phone.security.enabled
|
||||||
unlockedServicesLoaded.value = false
|
unlockedServicesLoaded.value = false
|
||||||
controlCenterOpened.value = false
|
controlCenterOpened.value = false
|
||||||
@@ -1637,8 +1680,18 @@ watch(
|
|||||||
startPasscodeLock(passcodeRetrySeconds.value)
|
startPasscodeLock(passcodeRetrySeconds.value)
|
||||||
}
|
}
|
||||||
phone.setLaunchOrigin(null)
|
phone.setLaunchOrigin(null)
|
||||||
if (isLocked.value || setupRequired.value) void router.replace('/')
|
if (setupRequired.value) {
|
||||||
else loadUnlockedPhoneData()
|
void router.replace('/')
|
||||||
|
} else if (openHomeRequested.value) {
|
||||||
|
openHomeRequested.value = false
|
||||||
|
if (isLocked.value) pendingUnlockRoute.value = '/'
|
||||||
|
else {
|
||||||
|
void router.replace('/')
|
||||||
|
loadUnlockedPhoneData()
|
||||||
|
}
|
||||||
|
} else if (!isLocked.value) {
|
||||||
|
loadUnlockedPhoneData()
|
||||||
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1650,6 +1703,7 @@ watch(
|
|||||||
)
|
)
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
|
removePhoneAudioController?.()
|
||||||
updateTextInputFocus(false)
|
updateTextInputFocus(false)
|
||||||
cancelUnlockedPhoneDataLoad()
|
cancelUnlockedPhoneDataLoad()
|
||||||
weather.stop()
|
weather.stop()
|
||||||
@@ -1688,12 +1742,15 @@ onBeforeUnmount(() => {
|
|||||||
phone.isOpen ||
|
phone.isOpen ||
|
||||||
notifications.current ||
|
notifications.current ||
|
||||||
calls.activeCall ||
|
calls.activeCall ||
|
||||||
|
dynamicIslandActivity ||
|
||||||
notifications.devicePreviews.length
|
notifications.devicePreviews.length
|
||||||
"
|
"
|
||||||
class="phone-stage"
|
class="phone-stage"
|
||||||
:class="{
|
:class="{
|
||||||
'phone-stage--browser-preview': isBrowserPreview,
|
'phone-stage--browser-preview': isBrowserPreview,
|
||||||
'phone-stage--landscape': phone.cameraLandscape,
|
'phone-stage--landscape': phone.cameraLandscape,
|
||||||
|
'phone-stage--live-activity':
|
||||||
|
!phone.isOpen && Boolean(dynamicIslandActivity || calls.activeCall),
|
||||||
'phone-stage--peek': notifications.isPeeking,
|
'phone-stage--peek': notifications.isPeeking,
|
||||||
}"
|
}"
|
||||||
:style="phoneStageStyle"
|
:style="phoneStageStyle"
|
||||||
@@ -1713,14 +1770,25 @@ onBeforeUnmount(() => {
|
|||||||
@open="openNotificationPreview"
|
@open="openNotificationPreview"
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
v-if="phone.isOpen || notifications.current || calls.activeCall"
|
v-if="
|
||||||
|
phone.isOpen ||
|
||||||
|
notifications.current ||
|
||||||
|
calls.activeCall ||
|
||||||
|
dynamicIslandActivity
|
||||||
|
"
|
||||||
class="phone-resolution-wrapper phone-resolution-wrapper--primary"
|
class="phone-resolution-wrapper phone-resolution-wrapper--primary"
|
||||||
>
|
>
|
||||||
|
<div
|
||||||
|
id="phone-home-drag-portal"
|
||||||
|
class="phone-home-drag-portal"
|
||||||
|
aria-hidden="true"
|
||||||
|
></div>
|
||||||
<div class="phone-resolution-canvas phone-resolution-canvas--primary">
|
<div class="phone-resolution-canvas phone-resolution-canvas--primary">
|
||||||
<section
|
<section
|
||||||
class="phone-device"
|
class="phone-device"
|
||||||
:class="{
|
:class="{
|
||||||
'phone-app--light': !displayedDarkMode,
|
'phone-app--light': !displayedDarkMode,
|
||||||
|
'phone-device--island-expanded': dynamicIslandExpanded,
|
||||||
[`phone-app--${phone.preferences.settings.graphicsMode}`]: true,
|
[`phone-app--${phone.preferences.settings.graphicsMode}`]: true,
|
||||||
}"
|
}"
|
||||||
:aria-label="phone.t('Common.phone')"
|
:aria-label="phone.t('Common.phone')"
|
||||||
@@ -1832,7 +1900,6 @@ onBeforeUnmount(() => {
|
|||||||
@control-center="toggleControlCenter"
|
@control-center="toggleControlCenter"
|
||||||
@lock="lockPhone"
|
@lock="lockPhone"
|
||||||
/>
|
/>
|
||||||
<PhoneDynamicIsland v-if="!setupRequired" />
|
|
||||||
<SpringboardView
|
<SpringboardView
|
||||||
v-if="!isDevelopmentRoute && !setupRequired"
|
v-if="!isDevelopmentRoute && !setupRequired"
|
||||||
@edit-mode-change="springboardEditing = $event"
|
@edit-mode-change="springboardEditing = $event"
|
||||||
@@ -1918,6 +1985,11 @@ onBeforeUnmount(() => {
|
|||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
draggable="false"
|
draggable="false"
|
||||||
/>
|
/>
|
||||||
|
<PhoneDynamicIsland
|
||||||
|
v-if="!setupRequired && !isDynamicIslandGalleryRoute"
|
||||||
|
@expanded-change="dynamicIslandExpanded = $event"
|
||||||
|
@live-activity-change="dynamicIslandActivity = $event"
|
||||||
|
/>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { readFileSync } from 'node:fs'
|
|||||||
|
|
||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
const source = readFileSync(new URL('./App.vue', import.meta.url), 'utf8')
|
const source = readFileSync(new URL('./App.vue', import.meta.url), 'utf8').replace(/\r\n/g, '\n')
|
||||||
const mainCss = readFileSync(
|
const mainCss = readFileSync(
|
||||||
new URL('./assets/main.css', import.meta.url),
|
new URL('./assets/main.css', import.meta.url),
|
||||||
'utf8',
|
'utf8',
|
||||||
@@ -22,15 +22,32 @@ describe('browser development preview contract', () => {
|
|||||||
|
|
||||||
it('starts unlocked while preserving an explicit lock screen preview', () => {
|
it('starts unlocked while preserving an explicit lock screen preview', () => {
|
||||||
expect(source).toContain("developmentParameters.has('lockScreenPreview')")
|
expect(source).toContain("developmentParameters.has('lockScreenPreview')")
|
||||||
expect(source).toContain(': !isDevelopment || developmentLockScreenPreview')
|
expect(source).toContain(
|
||||||
|
'developmentLockScreenPreview ||\n (!isDevelopment && phone.security.enabled)',
|
||||||
|
)
|
||||||
expect(source).toContain("developmentParameters.has('setupPreview')")
|
expect(source).toContain("developmentParameters.has('setupPreview')")
|
||||||
})
|
})
|
||||||
|
|
||||||
it('loads authenticated app data without replacing direct app routes', () => {
|
it('restores the active route after the lock screen', () => {
|
||||||
expect(source).toContain(
|
expect(source).toMatch(
|
||||||
|
/if \(setupRequired\.value\) \{[\s\S]*?router\.replace\('\/'\)/,
|
||||||
|
)
|
||||||
|
expect(source).toMatch(
|
||||||
|
/else if \(!isLocked\.value\) \{\s*loadUnlockedPhoneData\(\)/,
|
||||||
|
)
|
||||||
|
expect(source).not.toContain(
|
||||||
"if (isLocked.value || setupRequired.value) void router.replace('/')",
|
"if (isLocked.value || setupRequired.value) void router.replace('/')",
|
||||||
)
|
)
|
||||||
expect(source).toContain('else loadUnlockedPhoneData()')
|
})
|
||||||
|
|
||||||
|
it('opens Space-triggered live activities on Home or the enabled lock screen', () => {
|
||||||
|
expect(source).toContain(
|
||||||
|
'openHomeRequested.value = event.data.openHome === true',
|
||||||
|
)
|
||||||
|
expect(source).toContain(
|
||||||
|
"if (isLocked.value) pendingUnlockRoute.value = '/'",
|
||||||
|
)
|
||||||
|
expect(source).toContain("void router.replace('/')")
|
||||||
})
|
})
|
||||||
|
|
||||||
it('requires the passcode again after a full device lock', () => {
|
it('requires the passcode again after a full device lock', () => {
|
||||||
@@ -75,6 +92,10 @@ describe('browser development preview contract', () => {
|
|||||||
|
|
||||||
it('uses layout zoom so the fixed-resolution phone stays sharply rasterized', () => {
|
it('uses layout zoom so the fixed-resolution phone stays sharply rasterized', () => {
|
||||||
expect(source).toContain('phone-resolution-canvas--primary')
|
expect(source).toContain('phone-resolution-canvas--primary')
|
||||||
|
expect(source).toMatch(
|
||||||
|
/phone-resolution-wrapper--primary[\s\S]*?id="phone-home-drag-portal"[\s\S]*?phone-resolution-canvas--primary/,
|
||||||
|
)
|
||||||
|
expect(source.match(/id="phone-home-drag-portal"/g)).toHaveLength(1)
|
||||||
expect(source).toContain("'--phone-rendered-height'")
|
expect(source).toContain("'--phone-rendered-height'")
|
||||||
expect(source).toContain("'--phone-rendered-width'")
|
expect(source).toContain("'--phone-rendered-width'")
|
||||||
expect(mainCss).toMatch(
|
expect(mainCss).toMatch(
|
||||||
@@ -100,7 +121,9 @@ describe('browser development preview contract', () => {
|
|||||||
expect(source).toContain("developmentParameters.has('browserPreview')")
|
expect(source).toContain("developmentParameters.has('browserPreview')")
|
||||||
expect(source).toContain('import.meta.env.DEV ||')
|
expect(source).toContain('import.meta.env.DEV ||')
|
||||||
expect(source).toContain("'phone-stage--browser-preview': isBrowserPreview")
|
expect(source).toContain("'phone-stage--browser-preview': isBrowserPreview")
|
||||||
expect(source).toContain('return (availableScale * 0.94) / PHONE_BASE_SCALE')
|
expect(source).toContain(
|
||||||
|
'return (availableScale * 0.94) / PHONE_BASE_SCALE',
|
||||||
|
)
|
||||||
expect(mainCss).toMatch(
|
expect(mainCss).toMatch(
|
||||||
/\.phone-stage--browser-preview\s*\{[^}]*place-items:\s*center;[^}]*padding:\s*0;/s,
|
/\.phone-stage--browser-preview\s*\{[^}]*place-items:\s*center;[^}]*padding:\s*0;/s,
|
||||||
)
|
)
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
@@ -211,9 +211,11 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
:root {
|
:root {
|
||||||
font-family:
|
font-family: var(--sky-font-family);
|
||||||
-apple-system, BlinkMacSystemFont, 'SF Pro Display', 'Helvetica Neue',
|
font-feature-settings:
|
||||||
sans-serif;
|
'liga' 1,
|
||||||
|
'calt' 1;
|
||||||
|
font-optical-sizing: auto;
|
||||||
font-synthesis: none;
|
font-synthesis: none;
|
||||||
text-rendering: optimizeLegibility;
|
text-rendering: optimizeLegibility;
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
@@ -234,7 +236,9 @@ body,
|
|||||||
user-select: none;
|
user-select: none;
|
||||||
}
|
}
|
||||||
button,
|
button,
|
||||||
input {
|
input,
|
||||||
|
textarea,
|
||||||
|
select {
|
||||||
font: inherit;
|
font: inherit;
|
||||||
}
|
}
|
||||||
button {
|
button {
|
||||||
@@ -259,6 +263,20 @@ button {
|
|||||||
transform: translateY(calc(100% - 190px));
|
transform: translateY(calc(100% - 190px));
|
||||||
transform-origin: right bottom;
|
transform-origin: right bottom;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.phone-stage--live-activity .phone-resolution-wrapper--primary .phone-device {
|
||||||
|
pointer-events: none;
|
||||||
|
transform: translateY(
|
||||||
|
calc(100% - var(--phone-live-activity-peek-height, 145px))
|
||||||
|
);
|
||||||
|
transform-origin: right bottom;
|
||||||
|
}
|
||||||
|
.phone-stage--live-activity
|
||||||
|
.phone-device
|
||||||
|
> :not(.phone-screen):not(.phone-device__frame):not(.phone-dynamic-island),
|
||||||
|
.phone-stage--live-activity .phone-screen > * {
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
.phone-lift-enter-active {
|
.phone-lift-enter-active {
|
||||||
transition: opacity 0.52s linear;
|
transition: opacity 0.52s linear;
|
||||||
}
|
}
|
||||||
@@ -281,6 +299,18 @@ button {
|
|||||||
height: var(--phone-rendered-height, 844px);
|
height: var(--phone-rendered-height, 844px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.phone-home-drag-portal {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 70;
|
||||||
|
inset: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.phone-home-drag-portal * {
|
||||||
|
pointer-events: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
.phone-resolution-canvas {
|
.phone-resolution-canvas {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
top: 0;
|
||||||
@@ -384,7 +414,7 @@ button {
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border: 1px solid rgb(255 255 255 / 16%);
|
border: 1px solid rgb(255 255 255 / 16%);
|
||||||
border-radius: 15px;
|
border-radius: 15px;
|
||||||
color: #0a84ff;
|
color: #a9abb2;
|
||||||
background: rgb(47 47 51 / 98%);
|
background: rgb(47 47 51 / 98%);
|
||||||
box-shadow: 0 8px 24px rgb(0 0 0 / 28%);
|
box-shadow: 0 8px 24px rgb(0 0 0 / 28%);
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
@@ -672,6 +702,9 @@ button {
|
|||||||
transition: transform 280ms var(--sky-ease-out, ease-out);
|
transition: transform 280ms var(--sky-ease-out, ease-out);
|
||||||
will-change: transform;
|
will-change: transform;
|
||||||
}
|
}
|
||||||
|
.phone-device--island-expanded .phone-notification {
|
||||||
|
top: 130px !important;
|
||||||
|
}
|
||||||
.phone-notification__icon {
|
.phone-notification__icon {
|
||||||
width: 38px;
|
width: 38px;
|
||||||
height: 38px;
|
height: 38px;
|
||||||
@@ -795,78 +828,6 @@ button {
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
pointer-events: auto;
|
pointer-events: auto;
|
||||||
}
|
}
|
||||||
.phone-dynamic-island {
|
|
||||||
position: absolute;
|
|
||||||
z-index: 98;
|
|
||||||
top: 12px;
|
|
||||||
left: 50%;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
width: 300px;
|
|
||||||
min-height: 72px;
|
|
||||||
padding: 10px 12px 10px 16px;
|
|
||||||
border: 1px solid rgb(255 255 255 / 10%);
|
|
||||||
border-radius: 28px;
|
|
||||||
color: #fff;
|
|
||||||
background: #050505;
|
|
||||||
box-shadow: 0 8px 24px rgb(0 0 0 / 45%);
|
|
||||||
transform: translateX(-50%);
|
|
||||||
}
|
|
||||||
.phone-dynamic-island__caller {
|
|
||||||
min-width: 0;
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
.phone-dynamic-island__caller span,
|
|
||||||
.phone-dynamic-island__caller strong {
|
|
||||||
display: block;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
.phone-dynamic-island__caller span {
|
|
||||||
color: rgb(255 255 255 / 58%);
|
|
||||||
font-size: 11px;
|
|
||||||
line-height: 14px;
|
|
||||||
}
|
|
||||||
.phone-dynamic-island__caller strong {
|
|
||||||
margin-top: 2px;
|
|
||||||
font-size: 15px;
|
|
||||||
line-height: 18px;
|
|
||||||
}
|
|
||||||
.phone-dynamic-island__actions {
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
.phone-dynamic-island__actions .sky-button--icon-only {
|
|
||||||
width: 44px;
|
|
||||||
min-width: 44px;
|
|
||||||
height: 44px;
|
|
||||||
}
|
|
||||||
.phone-dynamic-island__actions svg {
|
|
||||||
width: 19px;
|
|
||||||
height: 19px;
|
|
||||||
}
|
|
||||||
.phone-dynamic-island__answer {
|
|
||||||
--sky-app-accent: #34c759;
|
|
||||||
}
|
|
||||||
.phone-dynamic-island-enter-active,
|
|
||||||
.phone-dynamic-island-leave-active {
|
|
||||||
transition:
|
|
||||||
opacity 180ms ease,
|
|
||||||
transform 220ms cubic-bezier(0.22, 1, 0.36, 1);
|
|
||||||
}
|
|
||||||
.phone-dynamic-island-enter-from,
|
|
||||||
.phone-dynamic-island-leave-to {
|
|
||||||
opacity: 0;
|
|
||||||
transform: translateX(-50%) scale(0.86);
|
|
||||||
}
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
|
||||||
.phone-dynamic-island-enter-active,
|
|
||||||
.phone-dynamic-island-leave-active {
|
|
||||||
transition: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.phone-home-indicator {
|
.phone-home-indicator {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
z-index: 90;
|
z-index: 90;
|
||||||
@@ -1341,8 +1302,7 @@ button {
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: #000;
|
background: #000;
|
||||||
color: #f5f5f7;
|
color: #f5f5f7;
|
||||||
font-family:
|
font-family: var(--sky-font-family);
|
||||||
-apple-system, BlinkMacSystemFont, 'SF Pro Display', 'Segoe UI', sans-serif;
|
|
||||||
}
|
}
|
||||||
.darkchat-page button,
|
.darkchat-page button,
|
||||||
.darkchat-page input,
|
.darkchat-page input,
|
||||||
@@ -2677,8 +2637,30 @@ button {
|
|||||||
.springboard--widget-dragging .springboard-widget-page-scroll {
|
.springboard--widget-dragging .springboard-widget-page-scroll {
|
||||||
overflow: visible;
|
overflow: visible;
|
||||||
}
|
}
|
||||||
.springboard--home-dragging .springboard-page--apps {
|
.home-drag-layer {
|
||||||
overflow: visible;
|
position: absolute;
|
||||||
|
overflow: hidden;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.home-drag-layer * {
|
||||||
|
pointer-events: none !important;
|
||||||
|
}
|
||||||
|
.home-drag-position {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
will-change: transform;
|
||||||
|
}
|
||||||
|
.home-drag-ghost {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
margin: 0;
|
||||||
|
opacity: 1;
|
||||||
|
transition: none !important;
|
||||||
|
animation: none !important;
|
||||||
|
transform-origin: 0 0;
|
||||||
|
will-change: transform;
|
||||||
}
|
}
|
||||||
.app-grid {
|
.app-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -2713,6 +2695,9 @@ button {
|
|||||||
transition: transform var(--springboard-page-duration)
|
transition: transform var(--springboard-page-duration)
|
||||||
var(--springboard-page-easing);
|
var(--springboard-page-easing);
|
||||||
}
|
}
|
||||||
|
.app-icon-item--drag-source {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
.app-icon-remove {
|
.app-icon-remove {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
z-index: 4;
|
z-index: 4;
|
||||||
@@ -2838,7 +2823,6 @@ button {
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
font-size: 11px;
|
|
||||||
}
|
}
|
||||||
.app-icon--calculator {
|
.app-icon--calculator {
|
||||||
background: linear-gradient(145deg, #76767b, #1b1b1d);
|
background: linear-gradient(145deg, #76767b, #1b1b1d);
|
||||||
@@ -7597,18 +7581,17 @@ button {
|
|||||||
min-height: var(--springboard-grid-row);
|
min-height: var(--springboard-grid-row);
|
||||||
}
|
}
|
||||||
.app-icon-button {
|
.app-icon-button {
|
||||||
font-family:
|
font-family: var(--sky-font-family);
|
||||||
-apple-system, BlinkMacSystemFont, 'SF Pro Text', 'Segoe UI', sans-serif;
|
|
||||||
touch-action: none;
|
touch-action: none;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
-webkit-user-select: none;
|
-webkit-user-select: none;
|
||||||
}
|
}
|
||||||
.app-icon-label {
|
.app-icon-label {
|
||||||
min-height: 15px;
|
min-height: var(--sky-home-label-height);
|
||||||
font-size: 11.5px;
|
font-size: var(--sky-home-label-font-size);
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
letter-spacing: -0.15px;
|
letter-spacing: -0.15px;
|
||||||
line-height: 15px;
|
line-height: var(--sky-home-label-height);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
.springboard-edit-add {
|
.springboard-edit-add {
|
||||||
|
|||||||
@@ -14,6 +14,10 @@ const callsServer = readFileSync(
|
|||||||
new URL('../../sky_phone/source/server/calls.lua', import.meta.url),
|
new URL('../../sky_phone/source/server/calls.lua', import.meta.url),
|
||||||
'utf8',
|
'utf8',
|
||||||
).replace(/\r\n/g, '\n')
|
).replace(/\r\n/g, '\n')
|
||||||
|
const phoneServer = readFileSync(
|
||||||
|
new URL('../../sky_phone/source/server/phone.lua', import.meta.url),
|
||||||
|
'utf8',
|
||||||
|
).replace(/\r\n/g, '\n')
|
||||||
const companiesStore = readFileSync(
|
const companiesStore = readFileSync(
|
||||||
new URL('./stores/companies.ts', import.meta.url),
|
new URL('./stores/companies.ts', import.meta.url),
|
||||||
'utf8',
|
'utf8',
|
||||||
@@ -85,3 +89,35 @@ describe('Companies outbound service-line call contract', () => {
|
|||||||
expect(startCompanyCall).not.toContain('data.callerNumber')
|
expect(startCompanyCall).not.toContain('data.callerNumber')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('Companies background call availability contract', () => {
|
||||||
|
it('keeps call availability enabled after the phone UI closes', () => {
|
||||||
|
const closeDevice = sourceBlock(
|
||||||
|
phoneServer,
|
||||||
|
`Bridge.Callbacks.Register(${quote}sky_phone:device:close${quote}`,
|
||||||
|
`Bridge.Callbacks.Register(${quote}sky_phone:device:notification-open${quote}`,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(closeDevice).toContain('sessions[source] = nil')
|
||||||
|
expect(closeDevice).not.toContain(
|
||||||
|
'SkyPhoneCompanies.ClearCallAvailability(source)',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('routes background calls only to an owned phone with the same registered SIM', () => {
|
||||||
|
const getCallTargets = sourceBlock(
|
||||||
|
companiesServer,
|
||||||
|
'function SkyPhoneCompanies.GetCallTargets(',
|
||||||
|
'\n\nlocal function profile_row(',
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(getCallTargets).toContain('SkyPhone.LoadDevice(readiness.imei)')
|
||||||
|
expect(getCallTargets).toContain(
|
||||||
|
'SkyPhone.FindDeviceSlots(source, readiness.imei)',
|
||||||
|
)
|
||||||
|
expect(getCallTargets).toContain('device.sim_id == readiness.sim_id')
|
||||||
|
expect(getCallTargets).toContain('device.sim_type == "registered"')
|
||||||
|
expect(getCallTargets).toContain('device.registered_at ~= nil')
|
||||||
|
expect(getCallTargets).not.toContain('current_device(source, true)')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -30,11 +30,13 @@ const props = withDefaults(
|
|||||||
app: PhoneAppDefinition
|
app: PhoneAppDefinition
|
||||||
compact?: boolean
|
compact?: boolean
|
||||||
editMode?: boolean
|
editMode?: boolean
|
||||||
|
externalDragVisual?: boolean
|
||||||
showLabel?: boolean
|
showLabel?: boolean
|
||||||
}>(),
|
}>(),
|
||||||
{
|
{
|
||||||
compact: false,
|
compact: false,
|
||||||
editMode: false,
|
editMode: false,
|
||||||
|
externalDragVisual: false,
|
||||||
showLabel: true,
|
showLabel: true,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -63,14 +65,14 @@ let dragStartPage = 0
|
|||||||
let dragPageWidth = 0
|
let dragPageWidth = 0
|
||||||
let dragPageMetrics: SpringboardDragMetrics | null = null
|
let dragPageMetrics: SpringboardDragMetrics | null = null
|
||||||
const dragStyle = computed(() =>
|
const dragStyle = computed(() =>
|
||||||
isDragging.value
|
isDragging.value && !props.externalDragVisual
|
||||||
? {
|
? {
|
||||||
transform: `translate3d(${springboardPageDragCompensation(dragStartPage, phone.currentPage, dragPageWidth)}px, 0, 0)`,
|
transform: `translate3d(${springboardPageDragCompensation(dragStartPage, phone.currentPage, dragPageWidth)}px, 0, 0)`,
|
||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
)
|
)
|
||||||
const dragPointerStyle = computed(() =>
|
const dragPointerStyle = computed(() =>
|
||||||
isDragging.value
|
isDragging.value && !props.externalDragVisual
|
||||||
? {
|
? {
|
||||||
transform: `translate3d(${dragOffset.value.x}px, ${dragOffset.value.y}px, 0)`,
|
transform: `translate3d(${dragOffset.value.x}px, ${dragOffset.value.y}px, 0)`,
|
||||||
}
|
}
|
||||||
@@ -300,6 +302,7 @@ onBeforeUnmount(() => {
|
|||||||
class="app-icon-item"
|
class="app-icon-item"
|
||||||
:class="{
|
:class="{
|
||||||
'app-icon-item--compact': compact,
|
'app-icon-item--compact': compact,
|
||||||
|
'app-icon-item--drag-source': isDragging && externalDragVisual,
|
||||||
'app-icon-item--dragging': isDragging,
|
'app-icon-item--dragging': isDragging,
|
||||||
'app-icon-item--editing': editMode,
|
'app-icon-item--editing': editMode,
|
||||||
}"
|
}"
|
||||||
|
|||||||
@@ -22,11 +22,13 @@ const props = withDefaults(
|
|||||||
apps: PhoneAppDefinition[]
|
apps: PhoneAppDefinition[]
|
||||||
defaultName: string
|
defaultName: string
|
||||||
editMode?: boolean
|
editMode?: boolean
|
||||||
|
externalDragVisual?: boolean
|
||||||
folder: HomeFolder
|
folder: HomeFolder
|
||||||
showLabel?: boolean
|
showLabel?: boolean
|
||||||
}>(),
|
}>(),
|
||||||
{
|
{
|
||||||
editMode: false,
|
editMode: false,
|
||||||
|
externalDragVisual: false,
|
||||||
showLabel: true,
|
showLabel: true,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -55,14 +57,14 @@ let stopPointerSession: (() => void) | null = null
|
|||||||
|
|
||||||
const folderName = computed(() => props.folder.name || props.defaultName)
|
const folderName = computed(() => props.folder.name || props.defaultName)
|
||||||
const dragStyle = computed(() =>
|
const dragStyle = computed(() =>
|
||||||
isDragging.value
|
isDragging.value && !props.externalDragVisual
|
||||||
? {
|
? {
|
||||||
transform: `translate3d(${springboardPageDragCompensation(dragStartPage, phone.currentPage, dragPageWidth)}px, 0, 0)`,
|
transform: `translate3d(${springboardPageDragCompensation(dragStartPage, phone.currentPage, dragPageWidth)}px, 0, 0)`,
|
||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
)
|
)
|
||||||
const dragPointerStyle = computed(() =>
|
const dragPointerStyle = computed(() =>
|
||||||
isDragging.value
|
isDragging.value && !props.externalDragVisual
|
||||||
? {
|
? {
|
||||||
transform: `translate3d(${dragOffset.value.x}px, ${dragOffset.value.y}px, 0)`,
|
transform: `translate3d(${dragOffset.value.x}px, ${dragOffset.value.y}px, 0)`,
|
||||||
}
|
}
|
||||||
@@ -232,6 +234,7 @@ onBeforeUnmount(() => {
|
|||||||
<div
|
<div
|
||||||
class="home-folder-item app-icon-item"
|
class="home-folder-item app-icon-item"
|
||||||
:class="{
|
:class="{
|
||||||
|
'app-icon-item--drag-source': isDragging && externalDragVisual,
|
||||||
'app-icon-item--dragging': isDragging,
|
'app-icon-item--dragging': isDragging,
|
||||||
'app-icon-item--editing': editMode,
|
'app-icon-item--editing': editMode,
|
||||||
'home-folder-item--dragging': isDragging,
|
'home-folder-item--dragging': isDragging,
|
||||||
|
|||||||
@@ -331,8 +331,7 @@ function finishPageSwipe(event: PointerEvent): void {
|
|||||||
z-index: 70;
|
z-index: 70;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
font-family:
|
font-family: var(--sky-font-family);
|
||||||
-apple-system, BlinkMacSystemFont, 'SF Pro Text', 'Segoe UI', sans-serif;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.home-folder-backdrop {
|
.home-folder-backdrop {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
|
|||||||
|
|
||||||
import payphoneFrame from '@/assets/img/payphone/american-payphone-frame.png'
|
import payphoneFrame from '@/assets/img/payphone/american-payphone-frame.png'
|
||||||
import { nuiCall } from '@/utils/nui'
|
import { nuiCall } from '@/utils/nui'
|
||||||
|
import { registerPhoneMediaElement } from '@/utils/phoneAudio'
|
||||||
import { isTrustedRootMessageSource } from '@/utils/windowMessages'
|
import { isTrustedRootMessageSource } from '@/utils/windowMessages'
|
||||||
|
|
||||||
type PayphoneState =
|
type PayphoneState =
|
||||||
@@ -70,7 +71,9 @@ const buttonSounds: HTMLAudioElement[] = []
|
|||||||
function prepareButtonSounds(): void {
|
function prepareButtonSounds(): void {
|
||||||
if (buttonSounds.length) return
|
if (buttonSounds.length) return
|
||||||
for (let index = 0; index < 4; index += 1) {
|
for (let index = 0; index < 4; index += 1) {
|
||||||
const sound = new Audio(`${import.meta.env.BASE_URL}sounds/button.mp3`)
|
const sound = registerPhoneMediaElement(
|
||||||
|
new Audio(`${import.meta.env.BASE_URL}sounds/button.mp3`),
|
||||||
|
)
|
||||||
sound.preload = 'auto'
|
sound.preload = 'auto'
|
||||||
sound.volume = 0.55
|
sound.volume = 0.55
|
||||||
buttonSounds.push(sound)
|
buttonSounds.push(sound)
|
||||||
@@ -399,7 +402,7 @@ onBeforeUnmount(() => {
|
|||||||
rgb(30 38 42 / 35%),
|
rgb(30 38 42 / 35%),
|
||||||
rgb(0 0 0 / 84%) 72%
|
rgb(0 0 0 / 84%) 72%
|
||||||
);
|
);
|
||||||
font-family: 'Segoe UI', Arial, sans-serif;
|
font-family: var(--sky-font-family);
|
||||||
pointer-events: auto;
|
pointer-events: auto;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,8 +5,15 @@ import { describe, expect, it } from 'vitest'
|
|||||||
const source = readFileSync(
|
const source = readFileSync(
|
||||||
new URL('./PhoneDynamicIsland.vue', import.meta.url),
|
new URL('./PhoneDynamicIsland.vue', import.meta.url),
|
||||||
'utf8',
|
'utf8',
|
||||||
|
).replace(/\r\n/g, '\n')
|
||||||
|
const appSource = readFileSync(
|
||||||
|
new URL('../App.vue', import.meta.url),
|
||||||
|
'utf8',
|
||||||
|
).replace(/\r\n/g, '\n')
|
||||||
|
const mainCss = readFileSync(
|
||||||
|
new URL('../assets/main.css', import.meta.url),
|
||||||
|
'utf8',
|
||||||
)
|
)
|
||||||
const appSource = readFileSync(new URL('../App.vue', import.meta.url), 'utf8')
|
|
||||||
const recorderSource = readFileSync(
|
const recorderSource = readFileSync(
|
||||||
new URL('./PhoneMemoRecorder.vue', import.meta.url),
|
new URL('./PhoneMemoRecorder.vue', import.meta.url),
|
||||||
'utf8',
|
'utf8',
|
||||||
@@ -19,16 +26,23 @@ const clockSource = readFileSync(
|
|||||||
new URL('../views/apps/ClockApp.vue', import.meta.url),
|
new URL('../views/apps/ClockApp.vue', import.meta.url),
|
||||||
'utf8',
|
'utf8',
|
||||||
)
|
)
|
||||||
|
const serverMemosSource = readFileSync(
|
||||||
|
new URL('../../../sky_phone/source/server/memos.lua', import.meta.url),
|
||||||
|
'utf8',
|
||||||
|
)
|
||||||
|
|
||||||
describe('Phone Dynamic Island contract', () => {
|
describe('Phone Dynamic Island contract', () => {
|
||||||
it('renders once at phone shell level instead of forcing calls into Phone', () => {
|
it('renders once at phone shell level instead of forcing calls into Phone', () => {
|
||||||
expect(appSource).toContain(
|
expect(appSource).toContain(
|
||||||
"import PhoneDynamicIsland from '@/components/PhoneDynamicIsland.vue'",
|
"import PhoneDynamicIsland from '@/components/PhoneDynamicIsland.vue'",
|
||||||
)
|
)
|
||||||
expect(appSource).toContain('<PhoneDynamicIsland v-if="!setupRequired" />')
|
|
||||||
expect(appSource).toContain(
|
expect(appSource).toContain(
|
||||||
'phone.isOpen || notifications.current || calls.activeCall',
|
"'phone-device--island-expanded': dynamicIslandExpanded",
|
||||||
)
|
)
|
||||||
|
expect(appSource).toMatch(
|
||||||
|
/class="phone-device__frame"[\s\S]*?<PhoneDynamicIsland[\s\S]*?@expanded-change="dynamicIslandExpanded = \$event"[\s\S]*?@live-activity-change="dynamicIslandActivity = \$event"/,
|
||||||
|
)
|
||||||
|
expect(appSource).toContain('dynamicIslandActivity ||')
|
||||||
expect(appSource).not.toContain(
|
expect(appSource).not.toContain(
|
||||||
"window.setTimeout(() => void router.push('/apps/phone'), 0)",
|
"window.setTimeout(() => void router.push('/apps/phone'), 0)",
|
||||||
)
|
)
|
||||||
@@ -45,7 +59,44 @@ describe('Phone Dynamic Island contract', () => {
|
|||||||
expect(source).toContain('call.answeredAt ?? call.startedAt')
|
expect(source).toContain('call.answeredAt ?? call.startedAt')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('hides an activity in its owning foreground app but restores it after closing', () => {
|
||||||
|
expect(source).toContain(
|
||||||
|
'if (!currentActivity || !phone.isOpen) return currentActivity',
|
||||||
|
)
|
||||||
|
expect(source).toContain("activeAppId.value === 'phone'")
|
||||||
|
expect(source).toContain(
|
||||||
|
"currentActivity === 'recording' && activeAppId.value === 'memos'",
|
||||||
|
)
|
||||||
|
expect(source).toContain("activeAppId.value === 'clock'")
|
||||||
|
expect(source).toContain(
|
||||||
|
"currentActivity === 'music' && activeAppId.value === 'music'",
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows only a compact frame and island for background live activities', () => {
|
||||||
|
expect(source).toContain("'live-activity-change': [activity:")
|
||||||
|
expect(source).toContain("emit('live-activity-change', nextActivity)")
|
||||||
|
expect(appSource).toContain(
|
||||||
|
"'phone-stage--live-activity':\n !phone.isOpen && Boolean(dynamicIslandActivity || calls.activeCall)",
|
||||||
|
)
|
||||||
|
expect(mainCss).toMatch(
|
||||||
|
/\.phone-stage--live-activity[\s\S]*?pointer-events:\s*none;[\s\S]*?--phone-live-activity-peek-height/,
|
||||||
|
)
|
||||||
|
expect(mainCss).toMatch(
|
||||||
|
/\.phone-stage--live-activity[\s\S]*?> :not\(\.phone-screen\):not\(\.phone-device__frame\):not\(\.phone-dynamic-island\)[\s\S]*?\.phone-screen > \*[\s\S]*?visibility:\s*hidden;/,
|
||||||
|
)
|
||||||
|
expect(appSource).toContain("? '190px'")
|
||||||
|
expect(appSource).toContain("? '132px'")
|
||||||
|
expect(appSource).toContain(": '112px'")
|
||||||
|
expect(source).toContain(
|
||||||
|
"!phone.isOpen ||\n activity.value === 'incoming-call' ||",
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
it('connects music, recorder, timer, and stopwatch controls to their stores', () => {
|
it('connects music, recorder, timer, and stopwatch controls to their stores', () => {
|
||||||
|
expect(source).toContain(
|
||||||
|
"if (music.isPlaying && music.currentTrack) return 'music'",
|
||||||
|
)
|
||||||
expect(source).toContain('@click.stop="music.previous()"')
|
expect(source).toContain('@click.stop="music.previous()"')
|
||||||
expect(source).toContain('@click.stop="music.toggle()"')
|
expect(source).toContain('@click.stop="music.toggle()"')
|
||||||
expect(source).toContain('@click.stop="music.next()"')
|
expect(source).toContain('@click.stop="music.next()"')
|
||||||
@@ -53,9 +104,31 @@ describe('Phone Dynamic Island contract', () => {
|
|||||||
expect(source).toContain('clock.pauseTimer(Date.now())')
|
expect(source).toContain('clock.pauseTimer(Date.now())')
|
||||||
expect(source).toContain('clock.pauseStopwatch(Date.now())')
|
expect(source).toContain('clock.pauseStopwatch(Date.now())')
|
||||||
expect(source).toContain('clock.addLap(Date.now())')
|
expect(source).toContain('clock.addLap(Date.now())')
|
||||||
|
expect(source).not.toContain('phone-dynamic-island__lap')
|
||||||
|
expect(source).toContain('phone-dynamic-island__stopwatch-meta')
|
||||||
|
expect(source).toContain('{{ stopwatchLapLabel }}')
|
||||||
|
expect(source).toContain('{{ stopwatchLapValue }}')
|
||||||
|
expect(source).toContain('{{ stopwatchTotalDisplay }}')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('keeps recorder state available across app changes', () => {
|
it('matches the reference music player and timer control layouts', () => {
|
||||||
|
expect(source).toContain('phone-dynamic-island__music-equalizer')
|
||||||
|
expect(source).toContain('phone-dynamic-island__progress-track')
|
||||||
|
expect(source).toContain('{{ musicElapsedLabel }}')
|
||||||
|
expect(source).toContain('{{ musicRemainingLabel }}')
|
||||||
|
expect(source).not.toContain('Airplay')
|
||||||
|
expect(source).toContain('<X aria-hidden="true" />')
|
||||||
|
expect(source).toMatch(
|
||||||
|
/\.phone-dynamic-island--timer\.phone-dynamic-island__copy|\.phone-dynamic-island--timer \.phone-dynamic-island__copy/,
|
||||||
|
)
|
||||||
|
expect(source).toContain(
|
||||||
|
'.phone-dynamic-island--music .phone-dynamic-island__actions--media',
|
||||||
|
)
|
||||||
|
expect(source).toContain('justify-content: center')
|
||||||
|
expect(source).toContain('gap: 34px')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps recorder state available across app changes and phone closes', () => {
|
||||||
expect(recorderSource).toContain(
|
expect(recorderSource).toContain(
|
||||||
"message.type === 'memo:recordStateRequest'",
|
"message.type === 'memo:recordStateRequest'",
|
||||||
)
|
)
|
||||||
@@ -65,6 +138,15 @@ describe('Phone Dynamic Island contract', () => {
|
|||||||
expect(memosSource).not.toContain(
|
expect(memosSource).not.toContain(
|
||||||
"if (recordingActive.value) postRecorderCommand('memo:recordCancel')",
|
"if (recordingActive.value) postRecorderCommand('memo:recordCancel')",
|
||||||
)
|
)
|
||||||
|
expect(recorderSource).toContain('() => phone.device?.imei ?? null')
|
||||||
|
expect(recorderSource).not.toContain(
|
||||||
|
'if (!isOpen || deviceSessionChanged) cancelRecording()',
|
||||||
|
)
|
||||||
|
expect(recorderSource).toContain('deviceImei: finalDeviceImei')
|
||||||
|
expect(serverMemosSource).toContain('device_owner(src, data.deviceImei)')
|
||||||
|
expect(serverMemosSource).toContain(
|
||||||
|
'SkyPhone.FindDeviceSlots(source, imei)[1]',
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('opens both clock live activities on the correct clock tab', () => {
|
it('opens both clock live activities on the correct clock tab', () => {
|
||||||
@@ -75,13 +157,68 @@ describe('Phone Dynamic Island contract', () => {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('opens activities by tapping the island without a separate expand icon', () => {
|
||||||
|
expect(source).toContain('@click.stop="toggleExpanded"')
|
||||||
|
expect(source).toContain('@click.stop="openActivity"')
|
||||||
|
expect(source).not.toContain('Maximize2')
|
||||||
|
expect(source).not.toContain('phone-dynamic-island__open-icon')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('collapses expanded activities on taps, swipes, and scrolling outside', () => {
|
||||||
|
expect(source).toContain('ref="islandElement"')
|
||||||
|
expect(source).toContain(
|
||||||
|
"document.addEventListener('pointerdown', onOutsidePointerDown, true)",
|
||||||
|
)
|
||||||
|
expect(source).toContain(
|
||||||
|
"document.addEventListener('scroll', collapseExpanded, true)",
|
||||||
|
)
|
||||||
|
expect(source).toContain('islandElement.value?.contains(event.target)')
|
||||||
|
expect(source).toContain('expanded.value = false')
|
||||||
|
expect(source).toContain(
|
||||||
|
"document.removeEventListener('pointerdown', onOutsidePointerDown, true)",
|
||||||
|
)
|
||||||
|
expect(source).toContain(
|
||||||
|
"document.removeEventListener('scroll', collapseExpanded, true)",
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
it('animates state changes and moves popup notifications below expanded UI', () => {
|
it('animates state changes and moves popup notifications below expanded UI', () => {
|
||||||
expect(source).toContain('<Transition name="phone-dynamic-island">')
|
expect(source).toContain('<Transition name="phone-dynamic-island">')
|
||||||
expect(source).toContain(
|
expect(source).toContain(
|
||||||
'<Transition name="phone-dynamic-island-content" mode="out-in">',
|
'<Transition name="phone-dynamic-island-content" mode="out-in">',
|
||||||
)
|
)
|
||||||
expect(source).toContain(".phone-dynamic-island[data-expanded='true']")
|
expect(source).toContain(".phone-dynamic-island[data-expanded='true']")
|
||||||
expect(source).toContain('~ .phone-notification-provider')
|
expect(source).toContain("emit('expanded-change', false)")
|
||||||
|
expect(mainCss).toContain(
|
||||||
|
'.phone-device--island-expanded .phone-notification',
|
||||||
|
)
|
||||||
expect(source).toContain('@media (prefers-reduced-motion: reduce)')
|
expect(source).toContain('@media (prefers-reduced-motion: reduce)')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('renders below the top edge and above the physical camera frame', () => {
|
||||||
|
expect(source).toMatch(
|
||||||
|
/\.phone-dynamic-island\s*\{[^}]*z-index:\s*102;[^}]*top:\s*30px;/s,
|
||||||
|
)
|
||||||
|
expect(mainCss).not.toMatch(/\.phone-dynamic-island\s*\{/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps compact and expanded islands close to the physical camera proportions', () => {
|
||||||
|
expect(source).toMatch(
|
||||||
|
/\.phone-dynamic-island\s*\{[^}]*width:\s*126px;[^}]*height:\s*38px;/s,
|
||||||
|
)
|
||||||
|
expect(source).toMatch(
|
||||||
|
/\.phone-dynamic-island\[data-expanded='true'\]\s*\{[^}]*width:\s*318px;[^}]*height:\s*74px;/s,
|
||||||
|
)
|
||||||
|
expect(source).toMatch(
|
||||||
|
/\.phone-dynamic-island--incoming-call\[data-expanded='true'\]\s*\{[^}]*height:\s*68px;/s,
|
||||||
|
)
|
||||||
|
expect(source).toMatch(
|
||||||
|
/\.phone-dynamic-island--music\[data-expanded='true'\]\s*\{[^}]*width:\s*316px;[^}]*height:\s*150px;/s,
|
||||||
|
)
|
||||||
|
expect(source).toMatch(
|
||||||
|
/\.phone-dynamic-island--stopwatch\[data-expanded='true'\]\s*\{[^}]*height:\s*70px;/s,
|
||||||
|
)
|
||||||
|
expect(source).toContain('box-sizing: border-box')
|
||||||
|
expect(source).toContain('padding: 8px 16px 8px 10px')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -14,6 +14,12 @@ import { isTrustedRootMessageSource } from '@/utils/windowMessages'
|
|||||||
|
|
||||||
type RecordingChunk = { blob: Blob; durationMs: number }
|
type RecordingChunk = { blob: Blob; durationMs: number }
|
||||||
type PendingVideo = { blob: Blob; fileName: string }
|
type PendingVideo = { blob: Blob; fileName: string }
|
||||||
|
type UploadFailureDebug = {
|
||||||
|
correlationId: string
|
||||||
|
message: string
|
||||||
|
stage: string
|
||||||
|
status?: number
|
||||||
|
}
|
||||||
|
|
||||||
const canvasRef = ref<HTMLCanvasElement | null>(null)
|
const canvasRef = ref<HTMLCanvasElement | null>(null)
|
||||||
const pendingVideos = new Map<string, PendingVideo>()
|
const pendingVideos = new Map<string, PendingVideo>()
|
||||||
@@ -130,7 +136,10 @@ function cleanupRecording(): void {
|
|||||||
try {
|
try {
|
||||||
activeRecorder.stop()
|
activeRecorder.stop()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[Camera] Could not stop the failed media recorder.', error)
|
console.error(
|
||||||
|
'[Camera] Could not stop the failed media recorder.',
|
||||||
|
error,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -245,8 +254,7 @@ async function startRecording(data: Record<string, unknown>): Promise<void> {
|
|||||||
const activeRecorder = recorder
|
const activeRecorder = recorder
|
||||||
removeRecorderErrorListener = bindMediaRecorderError(
|
removeRecorderErrorListener = bindMediaRecorderError(
|
||||||
activeRecorder,
|
activeRecorder,
|
||||||
() =>
|
() => generation === recordingGeneration && recorder === activeRecorder,
|
||||||
generation === recordingGeneration && recorder === activeRecorder,
|
|
||||||
(event) => {
|
(event) => {
|
||||||
console.error('[Camera] Media recorder failed while recording.', event)
|
console.error('[Camera] Media recorder failed while recording.', event)
|
||||||
cleanupRecording()
|
cleanupRecording()
|
||||||
@@ -417,8 +425,32 @@ async function capturePhotoBlob(ready: UploadReady): Promise<Blob> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function failUpload(requestId: string, error: string): Promise<void> {
|
async function failUpload(
|
||||||
await nuiCall('media:failUpload', { error, requestId })
|
requestId: string,
|
||||||
|
error: string,
|
||||||
|
debug: UploadFailureDebug,
|
||||||
|
): Promise<void> {
|
||||||
|
console.error('[Sky Phone Media] Upload failed.', {
|
||||||
|
correlationId: debug.correlationId,
|
||||||
|
detail: debug.message,
|
||||||
|
error,
|
||||||
|
stage: debug.stage,
|
||||||
|
status: debug.status,
|
||||||
|
})
|
||||||
|
const response = await nuiCall('media:failUpload', {
|
||||||
|
correlationId: debug.correlationId,
|
||||||
|
debugMessage: debug.message,
|
||||||
|
debugStage: debug.stage,
|
||||||
|
debugStatus: debug.status,
|
||||||
|
error,
|
||||||
|
requestId,
|
||||||
|
})
|
||||||
|
if (!response.success) {
|
||||||
|
console.error('[Sky Phone Media] Could not forward upload diagnostics.', {
|
||||||
|
correlationId: debug.correlationId,
|
||||||
|
error: response.error,
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function uploadReady(ready: UploadReady): Promise<void> {
|
async function uploadReady(ready: UploadReady): Promise<void> {
|
||||||
@@ -435,49 +467,87 @@ async function uploadReady(ready: UploadReady): Promise<void> {
|
|||||||
blob = await capturePhotoBlob(ready)
|
blob = await capturePhotoBlob(ready)
|
||||||
fileName = `camera-${ready.correlationId}.${ready.photo?.Encoding ?? 'jpg'}`
|
fileName = `camera-${ready.correlationId}.${ready.photo?.Encoding ?? 'jpg'}`
|
||||||
}
|
}
|
||||||
} catch {
|
} catch (error) {
|
||||||
await failUpload(ready.requestId, 'capture_failed')
|
await failUpload(ready.requestId, 'capture_failed', {
|
||||||
|
correlationId: ready.correlationId,
|
||||||
|
message: error instanceof Error ? error.message : String(error),
|
||||||
|
stage: 'capture',
|
||||||
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.info('[Sky Phone Media] Capture prepared for upload.', {
|
||||||
|
bytes: blob.size,
|
||||||
|
correlationId: ready.correlationId,
|
||||||
|
mimeType: blob.type,
|
||||||
|
type: ready.mediaType,
|
||||||
|
})
|
||||||
|
|
||||||
const form = new FormData()
|
const form = new FormData()
|
||||||
form.append('file', blob, fileName)
|
form.append('file', blob, fileName)
|
||||||
form.append(
|
|
||||||
'metadata',
|
|
||||||
JSON.stringify({ captureToken: ready.captureToken, source: 'sky_phone' }),
|
|
||||||
)
|
|
||||||
const controller = new AbortController()
|
const controller = new AbortController()
|
||||||
const timeout = window.setTimeout(
|
const timeout = window.setTimeout(
|
||||||
() => controller.abort(),
|
() => controller.abort(),
|
||||||
ready.uploadTimeoutMs ?? 25000,
|
ready.uploadTimeoutMs ?? 25000,
|
||||||
)
|
)
|
||||||
|
let debugStage = 'provider_request'
|
||||||
|
let debugStatus: number | undefined
|
||||||
try {
|
try {
|
||||||
const response = await fetch(ready.presignedUrl, {
|
const response = await fetch(ready.presignedUrl, {
|
||||||
body: form,
|
body: form,
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
})
|
})
|
||||||
|
debugStatus = response.status
|
||||||
|
debugStage = 'provider_response'
|
||||||
|
console.info('[Sky Phone Media] FiveManage upload responded.', {
|
||||||
|
correlationId: ready.correlationId,
|
||||||
|
status: response.status,
|
||||||
|
})
|
||||||
const text = await response.text()
|
const text = await response.text()
|
||||||
const body = JSON.parse(text) as {
|
const body = JSON.parse(text) as {
|
||||||
data?: { id?: string; url?: string }
|
data?: { id?: string; url?: string }
|
||||||
|
error?: string
|
||||||
id?: string
|
id?: string
|
||||||
|
message?: string
|
||||||
url?: string
|
url?: string
|
||||||
}
|
}
|
||||||
const uploaded = body.data ?? body
|
const uploaded = body.data ?? body
|
||||||
if (!response.ok || !uploaded.id || !uploaded.url) {
|
if (!response.ok || !uploaded.id || !uploaded.url) {
|
||||||
throw new Error('upload_failed')
|
throw new Error(
|
||||||
|
(typeof body.error === 'string' && body.error) ||
|
||||||
|
(typeof body.message === 'string' && body.message) ||
|
||||||
|
'upload_failed',
|
||||||
|
)
|
||||||
}
|
}
|
||||||
await nuiCall('media:completeUpload', {
|
debugStage = 'completion_callback'
|
||||||
|
const completion = await nuiCall('media:completeUpload', {
|
||||||
|
correlationId: ready.correlationId,
|
||||||
remoteId: uploaded.id,
|
remoteId: uploaded.id,
|
||||||
requestId: ready.requestId,
|
requestId: ready.requestId,
|
||||||
url: uploaded.url,
|
url: uploaded.url,
|
||||||
})
|
})
|
||||||
|
if (!completion.success) {
|
||||||
|
throw new Error(completion.error ?? 'completion_callback_failed')
|
||||||
|
}
|
||||||
|
console.info(
|
||||||
|
'[Sky Phone Media] Upload completion forwarded to the server.',
|
||||||
|
{
|
||||||
|
correlationId: ready.correlationId,
|
||||||
|
},
|
||||||
|
)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await failUpload(
|
await failUpload(
|
||||||
ready.requestId,
|
ready.requestId,
|
||||||
error instanceof DOMException && error.name === 'AbortError'
|
error instanceof DOMException && error.name === 'AbortError'
|
||||||
? 'upload_timeout'
|
? 'upload_timeout'
|
||||||
: 'upload_failed',
|
: 'upload_failed',
|
||||||
|
{
|
||||||
|
correlationId: ready.correlationId,
|
||||||
|
message: error instanceof Error ? error.message : String(error),
|
||||||
|
stage: debugStage,
|
||||||
|
status: debugStatus,
|
||||||
|
},
|
||||||
)
|
)
|
||||||
} finally {
|
} finally {
|
||||||
window.clearTimeout(timeout)
|
window.clearTimeout(timeout)
|
||||||
@@ -523,7 +593,12 @@ function onMessage(event: MessageEvent): void {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
} else if (message.type === 'media:uploadReady') {
|
} else if (message.type === 'media:uploadReady') {
|
||||||
void uploadReady(message.data as UploadReady)
|
const ready = message.data as UploadReady
|
||||||
|
console.info('[Sky Phone Media] Upload-ready received.', {
|
||||||
|
correlationId: ready.correlationId,
|
||||||
|
type: ready.mediaType,
|
||||||
|
})
|
||||||
|
void uploadReady(ready)
|
||||||
} else if (message.type === 'media:uploadResult') {
|
} else if (message.type === 'media:uploadResult') {
|
||||||
const correlationId = String(message.data?.correlationId ?? '')
|
const correlationId = String(message.data?.correlationId ?? '')
|
||||||
if (correlationId) pendingVideos.delete(correlationId)
|
if (correlationId) pendingVideos.delete(correlationId)
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ let metadata: MemoRecordingMetadata = { note: '', pinned: false, title: '' }
|
|||||||
let currentState: MemoRecorderStateName = 'idle'
|
let currentState: MemoRecorderStateName = 'idle'
|
||||||
let currentElapsedMs = 0
|
let currentElapsedMs = 0
|
||||||
let currentCorrelationId = ''
|
let currentCorrelationId = ''
|
||||||
|
let recordingDeviceImei = ''
|
||||||
let removeRecorderErrorListener: (() => void) | null = null
|
let removeRecorderErrorListener: (() => void) | null = null
|
||||||
|
|
||||||
function postRecorderState(state: MemoRecorderStateName, error?: string): void {
|
function postRecorderState(state: MemoRecorderStateName, error?: string): void {
|
||||||
@@ -153,6 +154,7 @@ function resetRecordingData(): void {
|
|||||||
pausedStartedAt = 0
|
pausedStartedAt = 0
|
||||||
totalPausedMs = 0
|
totalPausedMs = 0
|
||||||
currentElapsedMs = 0
|
currentElapsedMs = 0
|
||||||
|
recordingDeviceImei = ''
|
||||||
liveLevels = Array(LIVE_LEVEL_SAMPLES).fill(0.08)
|
liveLevels = Array(LIVE_LEVEL_SAMPLES).fill(0.08)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -189,7 +191,8 @@ function failRecording(error: string, generation = recordingGeneration): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function startRecording(data: Record<string, unknown>): Promise<void> {
|
async function startRecording(data: Record<string, unknown>): Promise<void> {
|
||||||
if (!phone.isOpen) {
|
const deviceImei = phone.device?.imei
|
||||||
|
if (!phone.isOpen || !deviceImei) {
|
||||||
console.error('[Memos] Cannot start a recording while the phone is closed.')
|
console.error('[Memos] Cannot start a recording while the phone is closed.')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -217,6 +220,7 @@ async function startRecording(data: Record<string, unknown>): Promise<void> {
|
|||||||
metadata = { note: '', pinned: false, title: '' }
|
metadata = { note: '', pinned: false, title: '' }
|
||||||
updateMetadata(data)
|
updateMetadata(data)
|
||||||
resetRecordingData()
|
resetRecordingData()
|
||||||
|
recordingDeviceImei = deviceImei
|
||||||
postRecorderState('starting')
|
postRecorderState('starting')
|
||||||
try {
|
try {
|
||||||
const acquiredStream = await navigator.mediaDevices.getUserMedia({
|
const acquiredStream = await navigator.mediaDevices.getUserMedia({
|
||||||
@@ -340,6 +344,7 @@ async function stopRecording(data: Record<string, unknown>): Promise<void> {
|
|||||||
if (generation !== recordingGeneration) return
|
if (generation !== recordingGeneration) return
|
||||||
const waveform = compressedWaveform()
|
const waveform = compressedWaveform()
|
||||||
const finalMetadata = { ...metadata }
|
const finalMetadata = { ...metadata }
|
||||||
|
const finalDeviceImei = recordingDeviceImei
|
||||||
const exceededSizeLimit = recordingTooLarge
|
const exceededSizeLimit = recordingTooLarge
|
||||||
cleanupRecorder(false)
|
cleanupRecorder(false)
|
||||||
resetRecordingData()
|
resetRecordingData()
|
||||||
@@ -355,10 +360,12 @@ async function stopRecording(data: Record<string, unknown>): Promise<void> {
|
|||||||
liveLevels = waveform.slice(-LIVE_LEVEL_SAMPLES)
|
liveLevels = waveform.slice(-LIVE_LEVEL_SAMPLES)
|
||||||
const uploadData = {
|
const uploadData = {
|
||||||
correlationId,
|
correlationId,
|
||||||
|
deviceImei: finalDeviceImei,
|
||||||
durationMs,
|
durationMs,
|
||||||
mimeType,
|
mimeType,
|
||||||
note: finalMetadata.note,
|
note: finalMetadata.note,
|
||||||
pinned: finalMetadata.pinned,
|
pinned: finalMetadata.pinned,
|
||||||
|
sizeBytes: blob.size,
|
||||||
title: finalMetadata.title,
|
title: finalMetadata.title,
|
||||||
waveform,
|
waveform,
|
||||||
}
|
}
|
||||||
@@ -452,14 +459,6 @@ async function uploadReady(ready: MemoUploadReady): Promise<void> {
|
|||||||
pending.requestId = ready.requestId
|
pending.requestId = ready.requestId
|
||||||
const form = new FormData()
|
const form = new FormData()
|
||||||
form.append('file', pending.blob, pending.fileName)
|
form.append('file', pending.blob, pending.fileName)
|
||||||
form.append(
|
|
||||||
'metadata',
|
|
||||||
JSON.stringify({
|
|
||||||
captureToken: ready.captureToken,
|
|
||||||
purpose: 'memo',
|
|
||||||
source: 'sky_phone',
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
const controller = new AbortController()
|
const controller = new AbortController()
|
||||||
pending.abortController = controller
|
pending.abortController = controller
|
||||||
const timeout = window.setTimeout(
|
const timeout = window.setTimeout(
|
||||||
@@ -569,18 +568,9 @@ function onMessage(event: MessageEvent): void {
|
|||||||
onMounted(() => window.addEventListener('message', onMessage))
|
onMounted(() => window.addEventListener('message', onMessage))
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() =>
|
() => phone.device?.imei ?? null,
|
||||||
[
|
(imei, previousImei) => {
|
||||||
phone.isOpen,
|
if (previousImei && imei !== previousImei) cancelRecording()
|
||||||
phone.device?.imei ?? null,
|
|
||||||
phone.deviceSessionToken,
|
|
||||||
] as const,
|
|
||||||
([isOpen, imei, sessionToken], previous) => {
|
|
||||||
const deviceSessionChanged =
|
|
||||||
previous !== undefined &&
|
|
||||||
previous[0] &&
|
|
||||||
(previous[1] !== imei || previous[2] !== sessionToken)
|
|
||||||
if (!isOpen || deviceSessionChanged) cancelRecording()
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -210,7 +210,7 @@ onBeforeUnmount(() => {
|
|||||||
z-index: 40;
|
z-index: 40;
|
||||||
display: flex;
|
display: flex;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
font-family: Inter, ui-sans-serif, system-ui, sans-serif;
|
font-family: var(--sky-font-family);
|
||||||
}
|
}
|
||||||
|
|
||||||
.radio-hud[data-horizontal='left'] {
|
.radio-hud[data-horizontal='left'] {
|
||||||
|
|||||||
@@ -677,9 +677,7 @@ onBeforeUnmount(() => {
|
|||||||
backdrop-filter: blur(26px) saturate(125%);
|
backdrop-filter: blur(26px) saturate(125%);
|
||||||
-webkit-backdrop-filter: blur(26px) saturate(125%);
|
-webkit-backdrop-filter: blur(26px) saturate(125%);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-family:
|
font-family: var(--sky-font-family);
|
||||||
-apple-system, BlinkMacSystemFont, 'SF Pro Display', 'SF Pro Text',
|
|
||||||
'Segoe UI', sans-serif;
|
|
||||||
user-select: none;
|
user-select: none;
|
||||||
-webkit-user-select: none;
|
-webkit-user-select: none;
|
||||||
touch-action: none;
|
touch-action: none;
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { getPhoneOutputVolume } from '@/utils/phoneAudio'
|
||||||
|
|
||||||
export type MemorySound = 'flip' | 'match' | 'mismatch' | 'win'
|
export type MemorySound = 'flip' | 'match' | 'mismatch' | 'win'
|
||||||
|
|
||||||
type Tone = {
|
type Tone = {
|
||||||
@@ -46,7 +48,10 @@ export function playMemorySound(sound: MemorySound, enabled: boolean): void {
|
|||||||
oscillator.type = tone.type
|
oscillator.type = tone.type
|
||||||
oscillator.frequency.setValueAtTime(tone.frequency, start)
|
oscillator.frequency.setValueAtTime(tone.frequency, start)
|
||||||
gain.gain.setValueAtTime(0.0001, start)
|
gain.gain.setValueAtTime(0.0001, start)
|
||||||
gain.gain.exponentialRampToValueAtTime(tone.volume, start + 0.012)
|
gain.gain.exponentialRampToValueAtTime(
|
||||||
|
Math.max(0.0001, tone.volume * getPhoneOutputVolume()),
|
||||||
|
start + 0.012,
|
||||||
|
)
|
||||||
gain.gain.exponentialRampToValueAtTime(0.0001, end)
|
gain.gain.exponentialRampToValueAtTime(0.0001, end)
|
||||||
oscillator.connect(gain)
|
oscillator.connect(gain)
|
||||||
gain.connect(audioContext.destination)
|
gain.connect(audioContext.destination)
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { registerPhoneMediaElement } from '@/utils/phoneAudio'
|
||||||
|
|
||||||
import flagUrl from '@/assets/audio/minesweeper/flag.wav?url'
|
import flagUrl from '@/assets/audio/minesweeper/flag.wav?url'
|
||||||
import clearUrl from '@/assets/audio/minesweeper/clear.wav?url'
|
import clearUrl from '@/assets/audio/minesweeper/clear.wav?url'
|
||||||
import mineUrl from '@/assets/audio/minesweeper/mine.wav?url'
|
import mineUrl from '@/assets/audio/minesweeper/mine.wav?url'
|
||||||
@@ -28,7 +30,7 @@ function getPlayers(sound: MinesweeperSound): HTMLAudioElement[] {
|
|||||||
if (existing) return existing
|
if (existing) return existing
|
||||||
|
|
||||||
const players = Array.from({ length: 3 }, () => {
|
const players = Array.from({ length: 3 }, () => {
|
||||||
const player = new Audio(soundUrls[sound])
|
const player = registerPhoneMediaElement(new Audio(soundUrls[sound]))
|
||||||
player.preload = 'auto'
|
player.preload = 'auto'
|
||||||
player.volume = 0.82
|
player.volume = 0.82
|
||||||
return player
|
return player
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { getPhoneOutputVolume } from '@/utils/phoneAudio'
|
||||||
|
|
||||||
export type NeonDropSound =
|
export type NeonDropSound =
|
||||||
| 'clear'
|
| 'clear'
|
||||||
| 'drop'
|
| 'drop'
|
||||||
@@ -48,7 +50,10 @@ function playSequence(sound: NeonDropSound): void {
|
|||||||
oscillator.type = type
|
oscillator.type = type
|
||||||
oscillator.frequency.setValueAtTime(frequency, start + delay)
|
oscillator.frequency.setValueAtTime(frequency, start + delay)
|
||||||
gain.gain.setValueAtTime(0.0001, start + delay)
|
gain.gain.setValueAtTime(0.0001, start + delay)
|
||||||
gain.gain.exponentialRampToValueAtTime(0.12, start + delay + 0.008)
|
gain.gain.exponentialRampToValueAtTime(
|
||||||
|
Math.max(0.0001, 0.12 * getPhoneOutputVolume()),
|
||||||
|
start + delay + 0.008,
|
||||||
|
)
|
||||||
gain.gain.exponentialRampToValueAtTime(0.0001, start + delay + duration)
|
gain.gain.exponentialRampToValueAtTime(0.0001, start + delay + duration)
|
||||||
oscillator.connect(gain)
|
oscillator.connect(gain)
|
||||||
gain.connect(context.destination)
|
gain.connect(context.destination)
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { registerPhoneMediaElement } from '@/utils/phoneAudio'
|
||||||
|
|
||||||
import gameOverUrl from '@/assets/audio/number-merge/game-over.wav?url'
|
import gameOverUrl from '@/assets/audio/number-merge/game-over.wav?url'
|
||||||
import mergeUrl from '@/assets/audio/number-merge/merge.wav?url'
|
import mergeUrl from '@/assets/audio/number-merge/merge.wav?url'
|
||||||
import moveUrl from '@/assets/audio/number-merge/move.wav?url'
|
import moveUrl from '@/assets/audio/number-merge/move.wav?url'
|
||||||
@@ -18,7 +20,7 @@ function getPlayers(sound: NumberMergeSound): HTMLAudioElement[] {
|
|||||||
if (existing) return existing
|
if (existing) return existing
|
||||||
|
|
||||||
const players = Array.from({ length: 3 }, () => {
|
const players = Array.from({ length: 3 }, () => {
|
||||||
const player = new Audio(soundUrls[sound])
|
const player = registerPhoneMediaElement(new Audio(soundUrls[sound]))
|
||||||
player.preload = 'auto'
|
player.preload = 'auto'
|
||||||
player.volume = 0.82
|
player.volume = 0.82
|
||||||
return player
|
return player
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { registerPhoneMediaElement } from '@/utils/phoneAudio'
|
||||||
|
|
||||||
import crashUrl from '@/assets/audio/sky-flappy/crash.wav?url'
|
import crashUrl from '@/assets/audio/sky-flappy/crash.wav?url'
|
||||||
import flapUrl from '@/assets/audio/sky-flappy/flap.wav?url'
|
import flapUrl from '@/assets/audio/sky-flappy/flap.wav?url'
|
||||||
import pointUrl from '@/assets/audio/sky-flappy/point.wav?url'
|
import pointUrl from '@/assets/audio/sky-flappy/point.wav?url'
|
||||||
@@ -11,7 +13,7 @@ export function playSkyFlappySound(sound: SkyFlappySound, enabled: boolean): voi
|
|||||||
let players = pools.get(sound)
|
let players = pools.get(sound)
|
||||||
if (!players) {
|
if (!players) {
|
||||||
players = Array.from({ length: 3 }, () => {
|
players = Array.from({ length: 3 }, () => {
|
||||||
const player = new Audio(urls[sound])
|
const player = registerPhoneMediaElement(new Audio(urls[sound]))
|
||||||
player.preload = 'auto'
|
player.preload = 'auto'
|
||||||
player.volume = 0.84
|
player.volume = 0.84
|
||||||
return player
|
return player
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { registerPhoneMediaElement } from '@/utils/phoneAudio'
|
||||||
|
|
||||||
import fallUrl from '@/assets/audio/tower-stack/fall.wav?url'
|
import fallUrl from '@/assets/audio/tower-stack/fall.wav?url'
|
||||||
import hitUrl from '@/assets/audio/tower-stack/hit.wav?url'
|
import hitUrl from '@/assets/audio/tower-stack/hit.wav?url'
|
||||||
import perfectUrl from '@/assets/audio/tower-stack/perfect.wav?url'
|
import perfectUrl from '@/assets/audio/tower-stack/perfect.wav?url'
|
||||||
@@ -18,7 +20,7 @@ function getPlayers(sound: TowerStackSound): HTMLAudioElement[] {
|
|||||||
if (existing) return existing
|
if (existing) return existing
|
||||||
|
|
||||||
const players = Array.from({ length: 3 }, () => {
|
const players = Array.from({ length: 3 }, () => {
|
||||||
const player = new Audio(soundUrls[sound])
|
const player = registerPhoneMediaElement(new Audio(soundUrls[sound]))
|
||||||
player.preload = 'auto'
|
player.preload = 'auto'
|
||||||
player.volume = 0.84
|
player.volume = 0.84
|
||||||
return player
|
return player
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import { readFileSync } from 'node:fs'
|
||||||
|
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
const mediaConfig = readFileSync(
|
||||||
|
new URL('../../sky_phone/config/media.lua', import.meta.url),
|
||||||
|
'utf8',
|
||||||
|
)
|
||||||
|
const manifest = readFileSync(
|
||||||
|
new URL('../../sky_phone/fxmanifest.lua', import.meta.url),
|
||||||
|
'utf8',
|
||||||
|
)
|
||||||
|
const mediaProviderConfig = readFileSync(
|
||||||
|
new URL(
|
||||||
|
'../../sky_phone/source/server/media_provider_config.lua',
|
||||||
|
import.meta.url,
|
||||||
|
),
|
||||||
|
'utf8',
|
||||||
|
)
|
||||||
|
const mediaImportAdapter = readFileSync(
|
||||||
|
new URL(
|
||||||
|
'../../sky_phone/source/server/media_import/fivemanage.lua',
|
||||||
|
import.meta.url,
|
||||||
|
),
|
||||||
|
'utf8',
|
||||||
|
)
|
||||||
|
const mediaServer = readFileSync(
|
||||||
|
new URL('../../sky_phone/source/server/media.lua', import.meta.url),
|
||||||
|
'utf8',
|
||||||
|
)
|
||||||
|
const memoServer = readFileSync(
|
||||||
|
new URL('../../sky_phone/source/server/memos.lua', import.meta.url),
|
||||||
|
'utf8',
|
||||||
|
)
|
||||||
|
const mediaCapture = readFileSync(
|
||||||
|
new URL('./components/PhoneMediaCapture.vue', import.meta.url),
|
||||||
|
'utf8',
|
||||||
|
)
|
||||||
|
const memoRecorder = readFileSync(
|
||||||
|
new URL('./components/PhoneMemoRecorder.vue', import.meta.url),
|
||||||
|
'utf8',
|
||||||
|
)
|
||||||
|
|
||||||
|
describe('FiveManage server configuration contract', () => {
|
||||||
|
it('keeps the provider token in the server-only media config', () => {
|
||||||
|
expect(mediaConfig).toMatch(/FiveManage\s*=\s*{\s*ApiKey\s*=/)
|
||||||
|
expect(mediaProviderConfig).toContain(
|
||||||
|
'return trim_key(Config.Media.FiveManage.ApiKey)',
|
||||||
|
)
|
||||||
|
expect(mediaProviderConfig).not.toContain('GetConvar')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses one resolver for Camera uploads and FiveManage imports', () => {
|
||||||
|
expect(
|
||||||
|
manifest.indexOf("'source/server/media_provider_config.lua'"),
|
||||||
|
).toBeLessThan(manifest.indexOf("'source/server/media_import.lua'"))
|
||||||
|
expect(mediaServer).toContain(
|
||||||
|
'SkyPhoneMediaProviderConfig.FiveManageApiKey()',
|
||||||
|
)
|
||||||
|
expect(mediaImportAdapter).toContain(
|
||||||
|
'SkyPhoneMediaProviderConfig.FiveManageApiKey(website.ApiKey)',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses the direct FiveManage upload response flow for Camera and voice memos', () => {
|
||||||
|
expect(mediaConfig).not.toContain('VerificationRetryDelaysMs')
|
||||||
|
expect(mediaCapture).toContain("form.append('file', blob, fileName)")
|
||||||
|
expect(mediaCapture).not.toContain("form.append('path'")
|
||||||
|
expect(mediaCapture).not.toContain("form.append(\n 'metadata'")
|
||||||
|
expect(memoRecorder).toContain(
|
||||||
|
"form.append('file', pending.blob, pending.fileName)",
|
||||||
|
)
|
||||||
|
expect(memoRecorder).not.toContain("form.append('path'")
|
||||||
|
expect(memoRecorder).not.toContain("form.append(\n 'metadata'")
|
||||||
|
expect(mediaServer).toContain(
|
||||||
|
'Accepting the direct FiveManage upload response',
|
||||||
|
)
|
||||||
|
expect(mediaServer).toContain('remote_id = remote_id')
|
||||||
|
expect(mediaServer).toContain('url = uploaded_url')
|
||||||
|
expect(mediaServer).not.toContain('"HEAD"')
|
||||||
|
expect(mediaServer).not.toContain('authenticated upload-path lookup')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('allowlists the FiveManage API and media hosts', () => {
|
||||||
|
expect(mediaServer).toContain('["api.fivemanage.com"] = true')
|
||||||
|
expect(mediaServer).toContain('["fmapi.net"] = true')
|
||||||
|
expect(mediaServer).toContain(
|
||||||
|
'uploaded_host:lower() ~= "r2.fivemanage.com"',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('validates and preserves the recorded memo size before upload', () => {
|
||||||
|
expect(memoRecorder).toContain('sizeBytes: blob.size')
|
||||||
|
expect(memoServer).toContain(
|
||||||
|
'local size_bytes = tonumber(data.sizeBytes)',
|
||||||
|
)
|
||||||
|
expect(memoServer).toContain(
|
||||||
|
'size_bytes < 1 or size_bytes > Config.Memos.MaximumBytes',
|
||||||
|
)
|
||||||
|
expect(memoServer).toContain('size_bytes = memo.size_bytes')
|
||||||
|
expect(mediaServer).toContain('size = state.size_bytes')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { readFileSync } from 'node:fs'
|
||||||
|
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
const mediaCapture = readFileSync(
|
||||||
|
new URL('./components/PhoneMediaCapture.vue', import.meta.url),
|
||||||
|
'utf8',
|
||||||
|
)
|
||||||
|
const mediaServer = readFileSync(
|
||||||
|
new URL('../../sky_phone/source/server/media.lua', import.meta.url),
|
||||||
|
'utf8',
|
||||||
|
)
|
||||||
|
|
||||||
|
describe('media upload diagnostics contracts', () => {
|
||||||
|
it('forwards browser upload failure context to the server log', () => {
|
||||||
|
expect(mediaCapture).toContain("let debugStage = 'provider_request'")
|
||||||
|
expect(mediaCapture).toContain('debugStatus: debug.status')
|
||||||
|
expect(mediaServer).toContain('Client-reported upload failure')
|
||||||
|
expect(mediaServer).toContain('diagnostic_text(data.debugMessage, 240)')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { readFileSync } from 'node:fs'
|
||||||
|
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
const appSource = readFileSync(new URL('./App.vue', import.meta.url), 'utf8')
|
||||||
|
const audioSource = readFileSync(
|
||||||
|
new URL('./utils/phoneAudio.ts', import.meta.url),
|
||||||
|
'utf8',
|
||||||
|
)
|
||||||
|
const mainCss = readFileSync(
|
||||||
|
new URL('./assets/main.css', import.meta.url),
|
||||||
|
'utf8',
|
||||||
|
)
|
||||||
|
const musicSource = readFileSync(
|
||||||
|
new URL('./stores/music.ts', import.meta.url),
|
||||||
|
'utf8',
|
||||||
|
)
|
||||||
|
|
||||||
|
describe('phone audio output contract', () => {
|
||||||
|
it('applies the hardware volume to app media and live YouTube playback', () => {
|
||||||
|
expect(appSource).toContain('installPhoneAudioController()')
|
||||||
|
expect(appSource).toContain('setPhoneOutputVolume(volume / 100)')
|
||||||
|
expect(appSource).toContain(
|
||||||
|
'if (radio.data.connected) void radio.setVolume(volume)',
|
||||||
|
)
|
||||||
|
expect(audioSource).toContain(
|
||||||
|
"document.addEventListener('play', onMediaPlay, true)",
|
||||||
|
)
|
||||||
|
expect(audioSource).toContain('localVolume * outputVolume')
|
||||||
|
expect(musicSource).toContain('registerPhoneMediaElement(new Audio())')
|
||||||
|
expect(musicSource).toContain('store.volume * getPhoneOutputVolume() * 100')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders the hardware speaker symbol in grey', () => {
|
||||||
|
expect(mainCss).toMatch(/\.phone-volume-hud\s*\{[\s\S]*?color:\s*#a9abb2;/)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -3,7 +3,7 @@ import { readFileSync } from 'node:fs'
|
|||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
const readResourceFile = (path: string) =>
|
const readResourceFile = (path: string) =>
|
||||||
readFileSync(new URL(`../../sky_phone/${path}`, import.meta.url), 'utf8')
|
readFileSync(new URL(`../../sky_phone/${path}`, import.meta.url), 'utf8').replace(/\r\n/g, '\n')
|
||||||
const readFrontendFile = (path: string) =>
|
const readFrontendFile = (path: string) =>
|
||||||
readFileSync(new URL(path, import.meta.url), 'utf8')
|
readFileSync(new URL(path, import.meta.url), 'utf8')
|
||||||
|
|
||||||
@@ -232,6 +232,22 @@ describe('phone inventory contracts', () => {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('opens a running live activity with Space without affecting normal gameplay', () => {
|
||||||
|
const phoneClient = readResourceFile('source/client/main.lua')
|
||||||
|
|
||||||
|
expect(phoneClient).toContain(
|
||||||
|
'RegisterCommand("sky_phone_live_activity_open"',
|
||||||
|
)
|
||||||
|
expect(phoneClient).toContain(
|
||||||
|
'if not live_activity_active or is_open or open_requested then',
|
||||||
|
)
|
||||||
|
expect(phoneClient).toContain(
|
||||||
|
'RegisterKeyMapping(\n "sky_phone_live_activity_open"',
|
||||||
|
)
|
||||||
|
expect(phoneClient).toContain('"SPACE"')
|
||||||
|
expect(phoneClient).toContain('RegisterNUICallback("ui:live-activity"')
|
||||||
|
})
|
||||||
|
|
||||||
it('keeps a server-selected unique handset as the preferred hotkey device', () => {
|
it('keeps a server-selected unique handset as the preferred hotkey device', () => {
|
||||||
const phoneServer = readResourceFile('source/server/phone.lua')
|
const phoneServer = readResourceFile('source/server/phone.lua')
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,12 @@ const developmentRoutes: RouteRecordRaw[] = import.meta.env.DEV
|
|||||||
name: 'development-sky-ui',
|
name: 'development-sky-ui',
|
||||||
path: '/development/sky-ui/:demo?',
|
path: '/development/sky-ui/:demo?',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
component: () =>
|
||||||
|
import('@/views/development/PhoneDynamicIslandGallery.vue'),
|
||||||
|
name: 'development-dynamic-islands',
|
||||||
|
path: '/development/dynamic-islands',
|
||||||
|
},
|
||||||
]
|
]
|
||||||
: []
|
: []
|
||||||
|
|
||||||
|
|||||||
@@ -64,6 +64,41 @@ describe('app store', () => {
|
|||||||
expect(apps.isInstalled('snake')).toBe(false)
|
expect(apps.isInstalled('snake')).toBe(false)
|
||||||
expect(apps.isInstalled('health')).toBe(true)
|
expect(apps.isInstalled('health')).toBe(true)
|
||||||
expect(apps.isInstalled('citywarn')).toBe(true)
|
expect(apps.isInstalled('citywarn')).toBe(true)
|
||||||
|
expect(apps.homeLayout.dock).toEqual([
|
||||||
|
'phone',
|
||||||
|
'messages',
|
||||||
|
'camera',
|
||||||
|
'clock',
|
||||||
|
])
|
||||||
|
for (const dockAppId of ['phone', 'messages', 'camera', 'clock']) {
|
||||||
|
expect(apps.homeLayout.grid).not.toContain(dockAppId)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('migrates current layouts so dock apps are not repeated in the grid', () => {
|
||||||
|
const apps = useAppStoreStore()
|
||||||
|
|
||||||
|
apps.hydrate({
|
||||||
|
homeLayout: {
|
||||||
|
dock: ['phone', 'messages', 'camera', 'clock'],
|
||||||
|
grid: ['phone', 'messages', 'calculator', 'camera', 'clock'],
|
||||||
|
hidden: [],
|
||||||
|
pageCount: 1,
|
||||||
|
version: HOME_LAYOUT_VERSION,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(apps.homeLayout.dock).toEqual([
|
||||||
|
'phone',
|
||||||
|
'messages',
|
||||||
|
'camera',
|
||||||
|
'clock',
|
||||||
|
])
|
||||||
|
for (const dockAppId of ['phone', 'messages', 'camera', 'clock']) {
|
||||||
|
expect(apps.homeLayout.grid).not.toContain(dockAppId)
|
||||||
|
}
|
||||||
|
expect(apps.homeLayout.grid).toContain('calculator')
|
||||||
|
expect(mocks.phone.saveDeviceNamespace).toHaveBeenCalledTimes(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('removes old automatic apps unless the player installed them', () => {
|
it('removes old automatic apps unless the player installed them', () => {
|
||||||
@@ -320,7 +355,7 @@ describe('app store', () => {
|
|||||||
const apps = useAppStoreStore()
|
const apps = useAppStoreStore()
|
||||||
apps.hydrate(null)
|
apps.hydrate(null)
|
||||||
mocks.phone.saveDeviceNamespace.mockClear()
|
mocks.phone.saveDeviceNamespace.mockClear()
|
||||||
const sourceIndex = apps.homeLayout.grid.indexOf('phone')
|
const sourceIndex = apps.homeLayout.grid.indexOf('calculator')
|
||||||
expect(sourceIndex).toBeGreaterThanOrEqual(0)
|
expect(sourceIndex).toBeGreaterThanOrEqual(0)
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
@@ -329,7 +364,7 @@ describe('app store', () => {
|
|||||||
HOME_GRID_PAGE_SIZE,
|
HOME_GRID_PAGE_SIZE,
|
||||||
]),
|
]),
|
||||||
).toBe(true)
|
).toBe(true)
|
||||||
expect(apps.homeLayout.grid[HOME_GRID_PAGE_SIZE]).toBe('phone')
|
expect(apps.homeLayout.grid[HOME_GRID_PAGE_SIZE]).toBe('calculator')
|
||||||
expect(mocks.phone.saveDeviceNamespace).toHaveBeenCalledTimes(1)
|
expect(mocks.phone.saveDeviceNamespace).toHaveBeenCalledTimes(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -386,18 +421,18 @@ describe('app store', () => {
|
|||||||
mocks.phone.saveDeviceNamespace.mockClear()
|
mocks.phone.saveDeviceNamespace.mockClear()
|
||||||
|
|
||||||
const notesIndex = apps.homeLayout.grid.indexOf('notes')
|
const notesIndex = apps.homeLayout.grid.indexOf('notes')
|
||||||
const clockIndex = apps.homeLayout.grid.indexOf('clock')
|
const settingsIndex = apps.homeLayout.grid.indexOf('settings')
|
||||||
const folderId = apps.createHomeFolder(
|
const folderId = apps.createHomeFolder(
|
||||||
'grid',
|
'grid',
|
||||||
notesIndex,
|
notesIndex,
|
||||||
'grid',
|
'grid',
|
||||||
clockIndex,
|
settingsIndex,
|
||||||
'Utilities',
|
'Utilities',
|
||||||
)
|
)
|
||||||
|
|
||||||
expect(folderId).toBeTruthy()
|
expect(folderId).toBeTruthy()
|
||||||
expect(getHomeFolder(apps.homeLayout, folderId!)?.apps).toEqual([
|
expect(getHomeFolder(apps.homeLayout, folderId!)?.apps).toEqual([
|
||||||
'clock',
|
'settings',
|
||||||
'notes',
|
'notes',
|
||||||
])
|
])
|
||||||
const mailIndex = apps.homeLayout.grid.indexOf('mail')
|
const mailIndex = apps.homeLayout.grid.indexOf('mail')
|
||||||
@@ -405,7 +440,7 @@ describe('app store', () => {
|
|||||||
apps.moveHomeFolderApp(folderId!, 2, 0)
|
apps.moveHomeFolderApp(folderId!, 2, 0)
|
||||||
apps.renameHomeFolder(folderId!, 'Work')
|
apps.renameHomeFolder(folderId!, 'Work')
|
||||||
expect(getHomeFolder(apps.homeLayout, folderId!)).toMatchObject({
|
expect(getHomeFolder(apps.homeLayout, folderId!)).toMatchObject({
|
||||||
apps: ['mail', 'notes', 'clock'],
|
apps: ['mail', 'notes', 'settings'],
|
||||||
name: 'Work',
|
name: 'Work',
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import {
|
|||||||
parseHomeLayout,
|
parseHomeLayout,
|
||||||
reflowHomeGridForWidgetChange,
|
reflowHomeGridForWidgetChange,
|
||||||
renameHomeFolder,
|
renameHomeFolder,
|
||||||
|
removeDockGridDuplicates,
|
||||||
removeHomeApp,
|
removeHomeApp,
|
||||||
restoreHomeApp,
|
restoreHomeApp,
|
||||||
type HomeArea,
|
type HomeArea,
|
||||||
@@ -45,7 +46,7 @@ const pendingInstallations = new WeakMap<
|
|||||||
>()
|
>()
|
||||||
|
|
||||||
function getDefaultGridIds(): LaunchablePhoneAppId[] {
|
function getDefaultGridIds(): LaunchablePhoneAppId[] {
|
||||||
return [...PHONE_APPS]
|
return PHONE_APPS.filter((app) => app.dockOrder === null)
|
||||||
.sort((a, b) => a.gridOrder - b.gridOrder)
|
.sort((a, b) => a.gridOrder - b.gridOrder)
|
||||||
.map((app) => app.id)
|
.map((app) => app.id)
|
||||||
}
|
}
|
||||||
@@ -263,12 +264,16 @@ export const useAppStoreStore = defineStore('app-store', {
|
|||||||
getDefaultGridIds(),
|
getDefaultGridIds(),
|
||||||
getDefaultDockIds(),
|
getDefaultDockIds(),
|
||||||
)
|
)
|
||||||
this.homeLayout = parseHomeLayout(
|
const parsedHomeLayout = parseHomeLayout(
|
||||||
data?.homeLayout,
|
data?.homeLayout,
|
||||||
defaults,
|
defaults,
|
||||||
installedIds,
|
installedIds,
|
||||||
false,
|
false,
|
||||||
)
|
)
|
||||||
|
const normalizedHomeLayout = removeDockGridDuplicates(parsedHomeLayout)
|
||||||
|
const removedDockGridDuplicates =
|
||||||
|
normalizedHomeLayout !== parsedHomeLayout
|
||||||
|
this.homeLayout = normalizedHomeLayout
|
||||||
const protectedHiddenAppIds =
|
const protectedHiddenAppIds =
|
||||||
this.homeLayout.hidden.filter(isProtectedHomeApp)
|
this.homeLayout.hidden.filter(isProtectedHomeApp)
|
||||||
for (const appId of protectedHiddenAppIds) {
|
for (const appId of protectedHiddenAppIds) {
|
||||||
@@ -292,6 +297,7 @@ export const useAppStoreStore = defineStore('app-store', {
|
|||||||
this.hydrated = true
|
this.hydrated = true
|
||||||
if (
|
if (
|
||||||
protectedHiddenAppIds.length ||
|
protectedHiddenAppIds.length ||
|
||||||
|
removedDockGridDuplicates ||
|
||||||
removedLegacyDefaults ||
|
removedLegacyDefaults ||
|
||||||
layoutVersion === 2 ||
|
layoutVersion === 2 ||
|
||||||
layoutVersion === 3 ||
|
layoutVersion === 3 ||
|
||||||
@@ -320,11 +326,8 @@ export const useAppStoreStore = defineStore('app-store', {
|
|||||||
getDefaultDockIds(),
|
getDefaultDockIds(),
|
||||||
)
|
)
|
||||||
const previous = JSON.stringify(this.homeLayout)
|
const previous = JSON.stringify(this.homeLayout)
|
||||||
this.homeLayout = parseHomeLayout(
|
this.homeLayout = removeDockGridDuplicates(
|
||||||
this.homeLayout,
|
parseHomeLayout(this.homeLayout, defaults, installedIds, false),
|
||||||
defaults,
|
|
||||||
installedIds,
|
|
||||||
false,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
for (const appId of [...this.homeLayout.hidden]) {
|
for (const appId of [...this.homeLayout.hidden]) {
|
||||||
|
|||||||
@@ -7,6 +7,11 @@ import type {
|
|||||||
MusicTrack,
|
MusicTrack,
|
||||||
} from '@/types/music'
|
} from '@/types/music'
|
||||||
import { nuiCall } from '@/utils/nui'
|
import { nuiCall } from '@/utils/nui'
|
||||||
|
import {
|
||||||
|
getPhoneOutputVolume,
|
||||||
|
registerPhoneMediaElement,
|
||||||
|
subscribePhoneOutputVolume,
|
||||||
|
} from '@/utils/phoneAudio'
|
||||||
|
|
||||||
export type YouTubePlayer = {
|
export type YouTubePlayer = {
|
||||||
destroy: () => void
|
destroy: () => void
|
||||||
@@ -48,7 +53,7 @@ declare global {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const audio = new Audio()
|
const audio = registerPhoneMediaElement(new Audio())
|
||||||
audio.preload = 'auto'
|
audio.preload = 'auto'
|
||||||
const YOUTUBE_API_TIMEOUT_MS = 12000
|
const YOUTUBE_API_TIMEOUT_MS = 12000
|
||||||
let audioBound = false
|
let audioBound = false
|
||||||
@@ -59,6 +64,11 @@ let youtubeApiPromise: Promise<YouTubeApi> | null = null
|
|||||||
let youtubePlayer: YouTubePlayer | null = null
|
let youtubePlayer: YouTubePlayer | null = null
|
||||||
let youtubeProgressTimer: number | null = null
|
let youtubeProgressTimer: number | null = null
|
||||||
|
|
||||||
|
subscribePhoneOutputVolume((volume) => {
|
||||||
|
const localVolume = useMusicStore().volume
|
||||||
|
youtubePlayer?.setVolume(localVolume * volume * 100)
|
||||||
|
})
|
||||||
|
|
||||||
export function musicTrackKey(
|
export function musicTrackKey(
|
||||||
track: Pick<MusicTrack, 'id' | 'source'>,
|
track: Pick<MusicTrack, 'id' | 'source'>,
|
||||||
): string {
|
): string {
|
||||||
@@ -276,7 +286,7 @@ async function loadYouTubeTrack(videoId: string): Promise<void> {
|
|||||||
const api = await loadYouTubeApi()
|
const api = await loadYouTubeApi()
|
||||||
const store = useMusicStore()
|
const store = useMusicStore()
|
||||||
if (youtubePlayer) {
|
if (youtubePlayer) {
|
||||||
youtubePlayer.setVolume(store.volume * 100)
|
youtubePlayer.setVolume(store.volume * getPhoneOutputVolume() * 100)
|
||||||
youtubePlayer.loadVideoById(videoId)
|
youtubePlayer.loadVideoById(videoId)
|
||||||
youtubePlayer.playVideo()
|
youtubePlayer.playVideo()
|
||||||
startYoutubeProgress()
|
startYoutubeProgress()
|
||||||
@@ -319,7 +329,9 @@ async function loadYouTubeTrack(videoId: string): Promise<void> {
|
|||||||
},
|
},
|
||||||
onReady: (event) => {
|
onReady: (event) => {
|
||||||
youtubePlayer = event.target
|
youtubePlayer = event.target
|
||||||
event.target.setVolume(useMusicStore().volume * 100)
|
event.target.setVolume(
|
||||||
|
useMusicStore().volume * getPhoneOutputVolume() * 100,
|
||||||
|
)
|
||||||
event.target.playVideo()
|
event.target.playVideo()
|
||||||
startYoutubeProgress()
|
startYoutubeProgress()
|
||||||
resolve()
|
resolve()
|
||||||
@@ -554,7 +566,7 @@ export const useMusicStore = defineStore('music', {
|
|||||||
setVolume(value: number): void {
|
setVolume(value: number): void {
|
||||||
this.volume = Math.max(0, Math.min(1, value))
|
this.volume = Math.max(0, Math.min(1, value))
|
||||||
audio.volume = this.volume
|
audio.volume = this.volume
|
||||||
youtubePlayer?.setVolume(this.volume * 100)
|
youtubePlayer?.setVolume(this.volume * getPhoneOutputVolume() * 100)
|
||||||
},
|
},
|
||||||
stop(): void {
|
stop(): void {
|
||||||
stopActiveMedia()
|
stopActiveMedia()
|
||||||
|
|||||||
@@ -4012,6 +4012,11 @@ const defaultLocales: LocaleTree = {
|
|||||||
invalid_media_type: 'The uploaded media type is invalid.',
|
invalid_media_type: 'The uploaded media type is invalid.',
|
||||||
invalid_upload: 'The upload could not be verified.',
|
invalid_upload: 'The upload could not be verified.',
|
||||||
invalid_upload_token: 'The upload session is no longer valid.',
|
invalid_upload_token: 'The upload session is no longer valid.',
|
||||||
|
media_provider_failed: 'The camera upload service is unavailable.',
|
||||||
|
media_provider_rate_limited:
|
||||||
|
'The camera upload service is busy. Try again shortly.',
|
||||||
|
media_provider_unauthorized:
|
||||||
|
'The configured FiveManage API key was rejected.',
|
||||||
missing_config: 'Camera uploads are not configured.',
|
missing_config: 'Camera uploads are not configured.',
|
||||||
microphone_unavailable:
|
microphone_unavailable:
|
||||||
'Allow microphone access or mute the microphone before recording.',
|
'Allow microphone access or mute the microphone before recording.',
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
export type DynamicIslandActivity =
|
||||||
|
| 'call'
|
||||||
|
| 'incoming-call'
|
||||||
|
| 'music'
|
||||||
|
| 'recording'
|
||||||
|
| 'stopwatch'
|
||||||
|
| 'timer'
|
||||||
@@ -70,7 +70,6 @@ export type MediaImportResult = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type UploadReady = {
|
export type UploadReady = {
|
||||||
captureToken: string
|
|
||||||
correlationId: string
|
correlationId: string
|
||||||
mediaType: MediaType
|
mediaType: MediaType
|
||||||
photo?: {
|
photo?: {
|
||||||
|
|||||||
@@ -39,7 +39,6 @@ export type MemoRecordingMetadata = {
|
|||||||
export type MemoUploadReady = {
|
export type MemoUploadReady = {
|
||||||
requestId: string
|
requestId: string
|
||||||
correlationId: string
|
correlationId: string
|
||||||
captureToken: string
|
|
||||||
presignedUrl: string
|
presignedUrl: string
|
||||||
uploadTimeoutMs?: number
|
uploadTimeoutMs?: number
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { existsSync, readdirSync, readFileSync } from 'node:fs'
|
||||||
|
import { join } from 'node:path'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
const sourceDirectory = fileURLToPath(new URL('..', import.meta.url))
|
||||||
|
const tokensSource = readFileSync(new URL('./tokens.css', import.meta.url), 'utf8')
|
||||||
|
const mainCssSource = readFileSync(
|
||||||
|
new URL('../assets/main.css', import.meta.url),
|
||||||
|
'utf8',
|
||||||
|
)
|
||||||
|
|
||||||
|
function styleSources(directory: string): Array<{
|
||||||
|
file: string
|
||||||
|
source: string
|
||||||
|
}> {
|
||||||
|
return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
|
||||||
|
const path = join(directory, entry.name)
|
||||||
|
if (entry.isDirectory()) return styleSources(path)
|
||||||
|
if (!/\.(?:css|vue)$/.test(entry.name)) return []
|
||||||
|
return [{ file: path, source: readFileSync(path, 'utf8') }]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Inter font contract', () => {
|
||||||
|
it('bundles normal and italic variable fonts for every supported weight', () => {
|
||||||
|
expect(
|
||||||
|
existsSync(new URL('../assets/fonts/InterVariable.woff2', import.meta.url)),
|
||||||
|
).toBe(true)
|
||||||
|
expect(
|
||||||
|
existsSync(
|
||||||
|
new URL('../assets/fonts/InterVariable-Italic.woff2', import.meta.url),
|
||||||
|
),
|
||||||
|
).toBe(true)
|
||||||
|
expect(tokensSource.match(/@font-face/g)).toHaveLength(2)
|
||||||
|
expect(tokensSource.match(/font-weight:\s*100 900;/g)).toHaveLength(2)
|
||||||
|
expect(tokensSource).toContain("--sky-font-family: 'Inter', Arial, sans-serif;")
|
||||||
|
})
|
||||||
|
|
||||||
|
it('applies the shared font to the document and native form controls', () => {
|
||||||
|
expect(mainCssSource).toMatch(
|
||||||
|
/:root\s*\{[\s\S]*?font-family:\s*var\(--sky-font-family\);/,
|
||||||
|
)
|
||||||
|
expect(mainCssSource).toMatch(
|
||||||
|
/button,\s*input,\s*textarea,\s*select\s*\{\s*font:\s*inherit;/,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not bypass the shared token with a generic system UI stack', () => {
|
||||||
|
const genericSystemStack =
|
||||||
|
/font-family\s*:\s*(?:-apple-system|BlinkMacSystemFont|system-ui|ui-sans-serif|['"]Segoe UI['"])/
|
||||||
|
const violations = styleSources(sourceDirectory)
|
||||||
|
.filter(({ source }) => genericSystemStack.test(source))
|
||||||
|
.map(({ file }) => file.slice(sourceDirectory.length + 1))
|
||||||
|
|
||||||
|
expect(violations).toEqual([])
|
||||||
|
})
|
||||||
|
})
|
||||||
Vendored
+2
-3
@@ -525,9 +525,8 @@
|
|||||||
height: var(--sky-widget-label-height);
|
height: var(--sky-widget-label-height);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
color: var(--sky-widget-label-color, #fff);
|
color: var(--sky-widget-label-color, #fff);
|
||||||
font-family:
|
font-family: var(--sky-font-family);
|
||||||
-apple-system, BlinkMacSystemFont, 'SF Pro Text', 'Segoe UI', sans-serif;
|
font-size: var(--sky-home-label-font-size);
|
||||||
font-size: 11.5px;
|
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
letter-spacing: -0.15px;
|
letter-spacing: -0.15px;
|
||||||
line-height: var(--sky-widget-label-height);
|
line-height: var(--sky-widget-label-height);
|
||||||
|
|||||||
@@ -1,3 +1,19 @@
|
|||||||
|
@font-face {
|
||||||
|
font-family: 'Inter';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 100 900;
|
||||||
|
font-display: swap;
|
||||||
|
src: url('../assets/fonts/InterVariable.woff2') format('woff2');
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Inter';
|
||||||
|
font-style: italic;
|
||||||
|
font-weight: 100 900;
|
||||||
|
font-display: swap;
|
||||||
|
src: url('../assets/fonts/InterVariable-Italic.woff2') format('woff2');
|
||||||
|
}
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--sky-device-pixel-ratio: 1;
|
--sky-device-pixel-ratio: 1;
|
||||||
--sky-hairline-scale: 1;
|
--sky-hairline-scale: 1;
|
||||||
@@ -26,9 +42,7 @@
|
|||||||
--sky-font-title: 17px;
|
--sky-font-title: 17px;
|
||||||
--sky-font-medium-title: 24px;
|
--sky-font-medium-title: 24px;
|
||||||
--sky-font-large-title: 34px;
|
--sky-font-large-title: 34px;
|
||||||
--sky-font-family:
|
--sky-font-family: 'Inter', Arial, sans-serif;
|
||||||
-apple-system, BlinkMacSystemFont, 'SF Pro Text', 'SF UI Text',
|
|
||||||
'Helvetica Neue', Helvetica, Arial, sans-serif;
|
|
||||||
--sky-transition-fast: 100ms;
|
--sky-transition-fast: 100ms;
|
||||||
--sky-transition-normal: 200ms;
|
--sky-transition-normal: 200ms;
|
||||||
--sky-ease-out: cubic-bezier(0.22, 1, 0.36, 1);
|
--sky-ease-out: cubic-bezier(0.22, 1, 0.36, 1);
|
||||||
@@ -55,8 +69,10 @@
|
|||||||
--sky-shadow-thumb:
|
--sky-shadow-thumb:
|
||||||
0 0.5px 4px rgba(0, 0, 0, 0.12), 0 6px 13px rgba(0, 0, 0, 0.12);
|
0 0.5px 4px rgba(0, 0, 0, 0.12), 0 6px 13px rgba(0, 0, 0, 0.12);
|
||||||
--sky-glass-highlight-color: rgba(255, 255, 255, 0.5);
|
--sky-glass-highlight-color: rgba(255, 255, 255, 0.5);
|
||||||
|
--sky-home-label-font-size: 13px;
|
||||||
|
--sky-home-label-height: 16px;
|
||||||
--sky-widget-label-gap: 5px;
|
--sky-widget-label-gap: 5px;
|
||||||
--sky-widget-label-height: 15px;
|
--sky-widget-label-height: var(--sky-home-label-height);
|
||||||
--sky-widget-radius-small: 23px;
|
--sky-widget-radius-small: 23px;
|
||||||
--sky-widget-radius-medium: 25px;
|
--sky-widget-radius-medium: 25px;
|
||||||
--sky-widget-radius-large: 28px;
|
--sky-widget-radius-large: 28px;
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
moveHomeFolderApp,
|
moveHomeFolderApp,
|
||||||
parseHomeLayout,
|
parseHomeLayout,
|
||||||
reflowHomeGridForWidgetChange,
|
reflowHomeGridForWidgetChange,
|
||||||
|
removeDockGridDuplicates,
|
||||||
removeHomeApp,
|
removeHomeApp,
|
||||||
renameHomeFolder,
|
renameHomeFolder,
|
||||||
restoreHomeApp,
|
restoreHomeApp,
|
||||||
@@ -78,6 +79,40 @@ describe('home layout', () => {
|
|||||||
expect(layout.version).toBe(HOME_LAYOUT_VERSION)
|
expect(layout.version).toBe(HOME_LAYOUT_VERSION)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('removes dock apps from the grid and normalizes affected folders', () => {
|
||||||
|
const layout: HomeLayout = {
|
||||||
|
...defaults,
|
||||||
|
dock: [
|
||||||
|
'phone',
|
||||||
|
{
|
||||||
|
apps: ['messages', 'mail'],
|
||||||
|
id: 'folder-abcdef',
|
||||||
|
name: 'Dock',
|
||||||
|
type: 'folder',
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
],
|
||||||
|
grid: [
|
||||||
|
'phone',
|
||||||
|
{
|
||||||
|
apps: ['messages', 'notes'],
|
||||||
|
id: 'folder-ghijkl',
|
||||||
|
name: 'Grid',
|
||||||
|
type: 'folder',
|
||||||
|
},
|
||||||
|
'mail',
|
||||||
|
'clock',
|
||||||
|
...Array.from({ length: HOME_GRID_PAGE_SIZE - 4 }, () => null),
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized = removeDockGridDuplicates(layout)
|
||||||
|
|
||||||
|
expect(normalized.grid.slice(0, 3)).toEqual(['notes', 'clock', null])
|
||||||
|
expect(normalized.dock).toEqual(layout.dock)
|
||||||
|
})
|
||||||
|
|
||||||
it('migrates compact persisted arrays and appends newly installed apps', () => {
|
it('migrates compact persisted arrays and appends newly installed apps', () => {
|
||||||
const layout = parseHomeLayout(
|
const layout = parseHomeLayout(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -505,6 +505,37 @@ export function createDefaultHomeLayout(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function removeDockGridDuplicates(layout: HomeLayout): HomeLayout {
|
||||||
|
const dockAppIds = new Set<LaunchablePhoneAppId>()
|
||||||
|
for (const item of layout.dock) {
|
||||||
|
if (typeof item === 'string') dockAppIds.add(item)
|
||||||
|
if (isHomeFolder(item)) {
|
||||||
|
for (const appId of item.apps) dockAppIds.add(appId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!dockAppIds.size) return layout
|
||||||
|
|
||||||
|
let changed = false
|
||||||
|
const grid = layout.grid.map((item): HomeSlot => {
|
||||||
|
if (typeof item === 'string') {
|
||||||
|
if (!dockAppIds.has(item)) return item
|
||||||
|
changed = true
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
if (!isHomeFolder(item)) return null
|
||||||
|
|
||||||
|
const apps = item.apps.filter((appId) => !dockAppIds.has(appId))
|
||||||
|
if (apps.length === item.apps.length) return cloneItem(item)
|
||||||
|
changed = true
|
||||||
|
return normalizeFolder({ ...item, apps })
|
||||||
|
})
|
||||||
|
if (!changed) return layout
|
||||||
|
|
||||||
|
const next = cloneLayout(layout)
|
||||||
|
next.grid = compactGridPages(grid)
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
export function parseHomeLayout(
|
export function parseHomeLayout(
|
||||||
value: unknown,
|
value: unknown,
|
||||||
defaults: HomeLayout,
|
defaults: HomeLayout,
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { runInNewContext } from 'node:vm'
|
||||||
|
|
||||||
import type { ExternalPhoneAppDefinition } from '@/types/apps'
|
import type { ExternalPhoneAppDefinition } from '@/types/apps'
|
||||||
import {
|
import {
|
||||||
@@ -139,6 +140,79 @@ describe('LB Phone app bridge', () => {
|
|||||||
expect(() => new Function(runtime)).not.toThrow()
|
expect(() => new Function(runtime)).not.toThrow()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('applies the LB iframe layout contract before revealing vendor apps', () => {
|
||||||
|
const document = createLbPhoneFrameDocument(
|
||||||
|
'<!doctype html><html><head></head><body style="visibility:hidden"></body></html>',
|
||||||
|
{
|
||||||
|
appName: 'radio-app',
|
||||||
|
localStorage: {},
|
||||||
|
resourceName: 'lb-radioapp',
|
||||||
|
settings: createLbPhoneHostSettings({
|
||||||
|
deviceName: 'Main phone',
|
||||||
|
isDarkMode: true,
|
||||||
|
language: 'en',
|
||||||
|
preferences: DEFAULT_PHONE_PREFERENCES,
|
||||||
|
securityEnabled: false,
|
||||||
|
}),
|
||||||
|
ui: 'https://cfx-nui-lb-radioapp/ui/dist/index.html',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
const runtime = /<script>([\s\S]*?)<\/script>/i.exec(document)?.[1]
|
||||||
|
expect(runtime).toBeTruthy()
|
||||||
|
|
||||||
|
const messageListeners: Array<(event: { data: unknown }) => void> = []
|
||||||
|
const readyListeners: Array<() => void> = []
|
||||||
|
const documentElement = { dataset: {}, style: {} }
|
||||||
|
const body = {
|
||||||
|
dataset: {},
|
||||||
|
style: { visibility: 'hidden' },
|
||||||
|
}
|
||||||
|
const sandbox = {
|
||||||
|
addEventListener(
|
||||||
|
eventName: string,
|
||||||
|
listener: (event: { data: unknown }) => void,
|
||||||
|
) {
|
||||||
|
if (eventName === 'message') messageListeners.push(listener)
|
||||||
|
},
|
||||||
|
componentsLoaded: undefined as boolean | undefined,
|
||||||
|
console,
|
||||||
|
document: {
|
||||||
|
addEventListener(eventName: string, listener: () => void) {
|
||||||
|
if (eventName === 'DOMContentLoaded') readyListeners.push(listener)
|
||||||
|
},
|
||||||
|
body,
|
||||||
|
documentElement,
|
||||||
|
},
|
||||||
|
parent: { postMessage() {} },
|
||||||
|
}
|
||||||
|
|
||||||
|
runInNewContext(runtime ?? '', sandbox)
|
||||||
|
expect(body.style.visibility).toBe('hidden')
|
||||||
|
|
||||||
|
expect(readyListeners).toHaveLength(1)
|
||||||
|
readyListeners[0]?.()
|
||||||
|
expect(documentElement.style).toMatchObject({
|
||||||
|
height: '100%',
|
||||||
|
margin: '0',
|
||||||
|
padding: '0',
|
||||||
|
width: '100%',
|
||||||
|
})
|
||||||
|
expect(body.dataset).toMatchObject({ device: 'phone', theme: 'dark' })
|
||||||
|
expect(body.style).toMatchObject({
|
||||||
|
height: '100%',
|
||||||
|
margin: '0',
|
||||||
|
padding: '0',
|
||||||
|
visibility: 'visible',
|
||||||
|
width: '100%',
|
||||||
|
})
|
||||||
|
|
||||||
|
body.style.visibility = 'hidden'
|
||||||
|
expect(messageListeners).toHaveLength(1)
|
||||||
|
messageListeners[0]?.({ data: 'componentsLoaded' })
|
||||||
|
expect(body.style.visibility).toBe('visible')
|
||||||
|
expect(sandbox.componentsLoaded).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
it('persists isolated LB localStorage snapshots without app changes', () => {
|
it('persists isolated LB localStorage snapshots without app changes', () => {
|
||||||
const values = new Map<string, string>()
|
const values = new Map<string, string>()
|
||||||
const storage = {
|
const storage = {
|
||||||
|
|||||||
@@ -130,6 +130,25 @@ function applySettings(nextSettings) {
|
|||||||
if (document.body) document.body.dataset.theme = theme;
|
if (document.body) document.body.dataset.theme = theme;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function prepareDocument() {
|
||||||
|
Object.assign(document.documentElement.style, {
|
||||||
|
height: '100%',
|
||||||
|
margin: '0',
|
||||||
|
padding: '0',
|
||||||
|
width: '100%'
|
||||||
|
});
|
||||||
|
if (!document.body) return;
|
||||||
|
|
||||||
|
document.body.dataset.device = 'phone';
|
||||||
|
Object.assign(document.body.style, {
|
||||||
|
height: '100%',
|
||||||
|
margin: '0',
|
||||||
|
padding: '0',
|
||||||
|
visibility: 'visible',
|
||||||
|
width: '100%'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
globalThis.resourceName = config.resourceName;
|
globalThis.resourceName = config.resourceName;
|
||||||
globalThis.appName = config.appName;
|
globalThis.appName = config.appName;
|
||||||
globalThis.components = globalThis.components ?? {};
|
globalThis.components = globalThis.components ?? {};
|
||||||
@@ -189,6 +208,11 @@ globalThis.getSettings = async () => globalThis.settings;
|
|||||||
|
|
||||||
globalThis.addEventListener('message', (event) => {
|
globalThis.addEventListener('message', (event) => {
|
||||||
const message = event.data;
|
const message = event.data;
|
||||||
|
if (message === 'componentsLoaded') {
|
||||||
|
globalThis.componentsLoaded = true;
|
||||||
|
prepareDocument();
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!message || typeof message !== 'object') return;
|
if (!message || typeof message !== 'object') return;
|
||||||
|
|
||||||
if (message.type === 'sky-phone:lb-settings') {
|
if (message.type === 'sky-phone:lb-settings') {
|
||||||
@@ -210,7 +234,10 @@ globalThis.addEventListener('message', (event) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
applySettings(config.settings);
|
applySettings(config.settings);
|
||||||
document.addEventListener('DOMContentLoaded', () => applySettings(globalThis.settings), { once: true });
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
prepareDocument();
|
||||||
|
applySettings(globalThis.settings);
|
||||||
|
}, { once: true });
|
||||||
`
|
`
|
||||||
|
|
||||||
function escapeAttribute(value: string): string {
|
function escapeAttribute(value: string): string {
|
||||||
|
|||||||
@@ -158,6 +158,13 @@ describe('media utilities', () => {
|
|||||||
expect(mediaErrorKey('profile_photo_required')).toBe(
|
expect(mediaErrorKey('profile_photo_required')).toBe(
|
||||||
'profile_photo_required',
|
'profile_photo_required',
|
||||||
)
|
)
|
||||||
|
expect(mediaErrorKey('media_provider_failed')).toBe('media_provider_failed')
|
||||||
|
expect(mediaErrorKey('media_provider_rate_limited')).toBe(
|
||||||
|
'media_provider_rate_limited',
|
||||||
|
)
|
||||||
|
expect(mediaErrorKey('media_provider_unauthorized')).toBe(
|
||||||
|
'media_provider_unauthorized',
|
||||||
|
)
|
||||||
expect(mediaErrorKey('private_provider_error')).toBe('request_failed')
|
expect(mediaErrorKey('private_provider_error')).toBe('request_failed')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -95,6 +95,9 @@ export function mediaErrorKey(error?: string): string {
|
|||||||
'invalid_import_url',
|
'invalid_import_url',
|
||||||
'invalid_upload',
|
'invalid_upload',
|
||||||
'invalid_upload_token',
|
'invalid_upload_token',
|
||||||
|
'media_provider_failed',
|
||||||
|
'media_provider_rate_limited',
|
||||||
|
'media_provider_unauthorized',
|
||||||
'missing_config',
|
'missing_config',
|
||||||
'import_media_not_allowed',
|
'import_media_not_allowed',
|
||||||
'import_media_too_large',
|
'import_media_too_large',
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
type PhoneAudioVolumeListener = (volume: number) => void
|
||||||
|
|
||||||
|
const mediaElements = new Map<HTMLMediaElement, boolean>()
|
||||||
|
const mediaLocalVolumes = new WeakMap<HTMLMediaElement, number>()
|
||||||
|
const volumeListeners = new Set<PhoneAudioVolumeListener>()
|
||||||
|
|
||||||
|
let documentListenerReferences = 0
|
||||||
|
let outputVolume = 1
|
||||||
|
|
||||||
|
function clampVolume(volume: number): number {
|
||||||
|
return Math.max(0, Math.min(1, Number.isFinite(volume) ? volume : 0))
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyMediaVolume(element: HTMLMediaElement): void {
|
||||||
|
const localVolume = mediaLocalVolumes.get(element) ?? element.volume
|
||||||
|
const nextVolume = clampVolume(localVolume * outputVolume)
|
||||||
|
if (Math.abs(element.volume - nextVolume) < 0.001) return
|
||||||
|
element.volume = nextVolume
|
||||||
|
}
|
||||||
|
|
||||||
|
function onMediaVolumeChange(event: Event): void {
|
||||||
|
const element = event.currentTarget as HTMLMediaElement
|
||||||
|
const currentLocalVolume = mediaLocalVolumes.get(element) ?? element.volume
|
||||||
|
const expectedVolume = clampVolume(currentLocalVolume * outputVolume)
|
||||||
|
if (Math.abs(element.volume - expectedVolume) < 0.001) return
|
||||||
|
mediaLocalVolumes.set(element, clampVolume(element.volume))
|
||||||
|
applyMediaVolume(element)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onMediaPlay(event: Event): void {
|
||||||
|
if (event.target instanceof HTMLMediaElement) {
|
||||||
|
trackPhoneMediaElement(event.target, false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function trackPhoneMediaElement<T extends HTMLMediaElement>(
|
||||||
|
element: T,
|
||||||
|
persistent: boolean,
|
||||||
|
): T {
|
||||||
|
if (mediaElements.has(element)) {
|
||||||
|
if (persistent) mediaElements.set(element, true)
|
||||||
|
return element
|
||||||
|
}
|
||||||
|
mediaElements.set(element, persistent)
|
||||||
|
mediaLocalVolumes.set(element, clampVolume(element.volume))
|
||||||
|
element.addEventListener('volumechange', onMediaVolumeChange)
|
||||||
|
applyMediaVolume(element)
|
||||||
|
return element
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPhoneOutputVolume(): number {
|
||||||
|
return outputVolume
|
||||||
|
}
|
||||||
|
|
||||||
|
export function installPhoneAudioController(): () => void {
|
||||||
|
documentListenerReferences += 1
|
||||||
|
if (documentListenerReferences === 1) {
|
||||||
|
document.addEventListener('play', onMediaPlay, true)
|
||||||
|
document
|
||||||
|
.querySelectorAll<HTMLMediaElement>('audio, video')
|
||||||
|
.forEach((element) => trackPhoneMediaElement(element, false))
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
documentListenerReferences = Math.max(0, documentListenerReferences - 1)
|
||||||
|
if (documentListenerReferences === 0) {
|
||||||
|
document.removeEventListener('play', onMediaPlay, true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerPhoneMediaElement<T extends HTMLMediaElement>(
|
||||||
|
element: T,
|
||||||
|
): T {
|
||||||
|
return trackPhoneMediaElement(element, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setPhoneOutputVolume(volume: number): void {
|
||||||
|
outputVolume = clampVolume(volume)
|
||||||
|
for (const [element, persistent] of mediaElements) {
|
||||||
|
if (!persistent && !element.isConnected && element.paused) {
|
||||||
|
element.removeEventListener('volumechange', onMediaVolumeChange)
|
||||||
|
mediaElements.delete(element)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
applyMediaVolume(element)
|
||||||
|
}
|
||||||
|
for (const listener of volumeListeners) listener(outputVolume)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function subscribePhoneOutputVolume(
|
||||||
|
listener: PhoneAudioVolumeListener,
|
||||||
|
): () => void {
|
||||||
|
volumeListeners.add(listener)
|
||||||
|
return () => volumeListeners.delete(listener)
|
||||||
|
}
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
import {
|
||||||
|
normalizePhoneViewportRect,
|
||||||
|
readPhoneViewportGeometry,
|
||||||
|
type PhoneViewportRect,
|
||||||
|
} from '@/utils/phoneViewportGeometry'
|
||||||
|
|
||||||
|
type RectMeasurement = Pick<
|
||||||
|
DOMRectReadOnly,
|
||||||
|
'height' | 'left' | 'top' | 'width'
|
||||||
|
>
|
||||||
|
|
||||||
|
function measuredRect(
|
||||||
|
left: number,
|
||||||
|
top: number,
|
||||||
|
width: number,
|
||||||
|
height: number,
|
||||||
|
): RectMeasurement {
|
||||||
|
return { height, left, top, width }
|
||||||
|
}
|
||||||
|
|
||||||
|
function expectRectClose(
|
||||||
|
actual: PhoneViewportRect,
|
||||||
|
expected: PhoneViewportRect,
|
||||||
|
): void {
|
||||||
|
for (const key of [
|
||||||
|
'bottom',
|
||||||
|
'height',
|
||||||
|
'left',
|
||||||
|
'right',
|
||||||
|
'top',
|
||||||
|
'width',
|
||||||
|
] as const) {
|
||||||
|
expect(actual[key]).toBeCloseTo(expected[key], 6)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createGeometryFixture(options: {
|
||||||
|
canvasOffsetHeight: number
|
||||||
|
canvasOffsetWidth: number
|
||||||
|
rawCanvasRect: RectMeasurement
|
||||||
|
wrapperRect: RectMeasurement
|
||||||
|
}): { anchor: Element; element: (rect: RectMeasurement) => Element } {
|
||||||
|
const wrapper = {
|
||||||
|
getBoundingClientRect: () => options.wrapperRect,
|
||||||
|
} as unknown as HTMLElement
|
||||||
|
const canvas = {
|
||||||
|
closest: (selector: string) =>
|
||||||
|
selector === '.phone-resolution-wrapper' ? wrapper : null,
|
||||||
|
getBoundingClientRect: () => options.rawCanvasRect,
|
||||||
|
offsetHeight: options.canvasOffsetHeight,
|
||||||
|
offsetWidth: options.canvasOffsetWidth,
|
||||||
|
} as unknown as HTMLElement
|
||||||
|
|
||||||
|
return {
|
||||||
|
anchor: {
|
||||||
|
closest: (selector: string) =>
|
||||||
|
selector === '.phone-resolution-canvas' ? canvas : null,
|
||||||
|
} as unknown as Element,
|
||||||
|
element: (rect) =>
|
||||||
|
({ getBoundingClientRect: () => rect }) as unknown as Element,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('phone viewport geometry', () => {
|
||||||
|
it('leaves modern Chrome measurements unchanged when the canvas BCR is already rendered', () => {
|
||||||
|
const wrapper = measuredRect(1573.09, 126.25, 322.92, 698.832)
|
||||||
|
const layer = measuredRect(1589.783336, 177.75, 289.533328, 603.2)
|
||||||
|
|
||||||
|
expectRectClose(normalizePhoneViewportRect(layer, wrapper, wrapper), {
|
||||||
|
bottom: layer.top + layer.height,
|
||||||
|
height: layer.height,
|
||||||
|
left: layer.left,
|
||||||
|
right: layer.left + layer.width,
|
||||||
|
top: layer.top,
|
||||||
|
width: layer.width,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calibrates live CEF 103 BCRs back inside the visible wrapper', () => {
|
||||||
|
const wrapper = measuredRect(1573.09, 126.25, 322.92, 698.832)
|
||||||
|
const rawCanvas = measuredRect(1899.87, 152.5, 389.98, 844)
|
||||||
|
const rawLayer = measuredRect(1920.03, 214.25, 349.66, 728.5)
|
||||||
|
const corrected = normalizePhoneViewportRect(rawLayer, rawCanvas, wrapper)
|
||||||
|
|
||||||
|
expect(corrected.left).toBeCloseTo(1589.7833360685163, 6)
|
||||||
|
expect(corrected.width).toBeCloseTo(289.5333278629674, 6)
|
||||||
|
expect(corrected.right).toBeCloseTo(1879.3166639314836, 6)
|
||||||
|
expect(corrected.left).toBeGreaterThan(wrapper.left)
|
||||||
|
expect(corrected.right).toBeLessThan(wrapper.left + wrapper.width)
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['80%', 0.6624],
|
||||||
|
['100%', 0.828],
|
||||||
|
['120%', 0.9936],
|
||||||
|
])(
|
||||||
|
'normalizes fractional positions, sizes, and deltas at %s scaling',
|
||||||
|
(_label, zoom) => {
|
||||||
|
const rawCanvas = measuredRect(1900.125, 212.75, 390, 844)
|
||||||
|
const wrapper = measuredRect(1530.5, 250.25, 390 * zoom, 844 * zoom)
|
||||||
|
const rawStart = measuredRect(
|
||||||
|
rawCanvas.left + 20.125,
|
||||||
|
rawCanvas.top + 50.375,
|
||||||
|
349.75,
|
||||||
|
73.125,
|
||||||
|
)
|
||||||
|
const rawEnd = measuredRect(
|
||||||
|
rawStart.left + 73.25,
|
||||||
|
rawStart.top - 41.75,
|
||||||
|
rawStart.width,
|
||||||
|
rawStart.height,
|
||||||
|
)
|
||||||
|
const start = normalizePhoneViewportRect(rawStart, rawCanvas, wrapper)
|
||||||
|
const end = normalizePhoneViewportRect(rawEnd, rawCanvas, wrapper)
|
||||||
|
|
||||||
|
expect(start.left).toBeCloseTo(wrapper.left + 20.125 * zoom, 6)
|
||||||
|
expect(start.top).toBeCloseTo(wrapper.top + 50.375 * zoom, 6)
|
||||||
|
expect(start.width).toBeCloseTo(349.75 * zoom, 6)
|
||||||
|
expect(start.height).toBeCloseTo(73.125 * zoom, 6)
|
||||||
|
expect(end.left - start.left).toBeCloseTo(73.25 * zoom, 6)
|
||||||
|
expect(end.top - start.top).toBeCloseTo(-41.75 * zoom, 6)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
it('reads visual scale and normalized element rects from a canvas anchor', () => {
|
||||||
|
const fixture = createGeometryFixture({
|
||||||
|
canvasOffsetHeight: 844,
|
||||||
|
canvasOffsetWidth: 390,
|
||||||
|
rawCanvasRect: measuredRect(1899.87, 152.5, 389.98, 844),
|
||||||
|
wrapperRect: measuredRect(1573.09, 126.25, 322.92, 698.832),
|
||||||
|
})
|
||||||
|
const geometry = readPhoneViewportGeometry(fixture.anchor)
|
||||||
|
const layer = fixture.element(measuredRect(1920.03, 214.25, 349.66, 728.5))
|
||||||
|
|
||||||
|
expect(geometry).not.toBeNull()
|
||||||
|
expect(geometry?.scaleX).toBeCloseTo(0.828, 6)
|
||||||
|
expect(geometry?.scaleY).toBeCloseTo(0.828, 6)
|
||||||
|
expect(geometry?.rect(layer).left).toBeCloseTo(1589.7833360685163, 6)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses finite identity fallbacks for zero geometry and missing anchors', () => {
|
||||||
|
const rawCanvas = measuredRect(100, 50, 0, 0)
|
||||||
|
const corrected = normalizePhoneViewportRect(
|
||||||
|
measuredRect(112.5, 58.25, 40, 20),
|
||||||
|
rawCanvas,
|
||||||
|
measuredRect(500, 300, 0, 0),
|
||||||
|
)
|
||||||
|
const fixture = createGeometryFixture({
|
||||||
|
canvasOffsetHeight: 0,
|
||||||
|
canvasOffsetWidth: 0,
|
||||||
|
rawCanvasRect: rawCanvas,
|
||||||
|
wrapperRect: measuredRect(500, 300, 0, 0),
|
||||||
|
})
|
||||||
|
const geometry = readPhoneViewportGeometry(fixture.anchor)
|
||||||
|
|
||||||
|
expect(corrected).toEqual({
|
||||||
|
bottom: 328.25,
|
||||||
|
height: 20,
|
||||||
|
left: 512.5,
|
||||||
|
right: 552.5,
|
||||||
|
top: 308.25,
|
||||||
|
width: 40,
|
||||||
|
})
|
||||||
|
expect(Object.values(corrected).every(Number.isFinite)).toBe(true)
|
||||||
|
expect(geometry?.scaleX).toBe(1)
|
||||||
|
expect(geometry?.scaleY).toBe(1)
|
||||||
|
expect(readPhoneViewportGeometry(null)).toBeNull()
|
||||||
|
expect(
|
||||||
|
readPhoneViewportGeometry({ closest: () => null } as unknown as Element),
|
||||||
|
).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
export type PhoneViewportRect = {
|
||||||
|
bottom: number
|
||||||
|
height: number
|
||||||
|
left: number
|
||||||
|
right: number
|
||||||
|
top: number
|
||||||
|
width: number
|
||||||
|
}
|
||||||
|
|
||||||
|
type RectMeasurement = Pick<
|
||||||
|
DOMRectReadOnly,
|
||||||
|
'height' | 'left' | 'top' | 'width'
|
||||||
|
>
|
||||||
|
|
||||||
|
export type PhoneViewportGeometry = {
|
||||||
|
readonly scaleX: number
|
||||||
|
readonly scaleY: number
|
||||||
|
rect(element: Element): PhoneViewportRect
|
||||||
|
}
|
||||||
|
|
||||||
|
function positiveRatio(numerator: number, denominator: number): number {
|
||||||
|
return Number.isFinite(numerator) &&
|
||||||
|
Number.isFinite(denominator) &&
|
||||||
|
numerator > 0 &&
|
||||||
|
denominator > 0
|
||||||
|
? numerator / denominator
|
||||||
|
: 1
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizePhoneViewportRect(
|
||||||
|
rawRect: RectMeasurement,
|
||||||
|
rawCanvasRect: RectMeasurement,
|
||||||
|
wrapperRect: RectMeasurement,
|
||||||
|
): PhoneViewportRect {
|
||||||
|
const factorX = positiveRatio(wrapperRect.width, rawCanvasRect.width)
|
||||||
|
const factorY = positiveRatio(wrapperRect.height, rawCanvasRect.height)
|
||||||
|
const left = wrapperRect.left + (rawRect.left - rawCanvasRect.left) * factorX
|
||||||
|
const top = wrapperRect.top + (rawRect.top - rawCanvasRect.top) * factorY
|
||||||
|
const width = rawRect.width * factorX
|
||||||
|
const height = rawRect.height * factorY
|
||||||
|
|
||||||
|
return {
|
||||||
|
bottom: top + height,
|
||||||
|
height,
|
||||||
|
left,
|
||||||
|
right: left + width,
|
||||||
|
top,
|
||||||
|
width,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readPhoneViewportGeometry(
|
||||||
|
anchor: Element | null,
|
||||||
|
): PhoneViewportGeometry | null {
|
||||||
|
const canvas = anchor?.closest<HTMLElement>('.phone-resolution-canvas')
|
||||||
|
const wrapper = canvas?.closest<HTMLElement>('.phone-resolution-wrapper')
|
||||||
|
if (!canvas || !wrapper) return null
|
||||||
|
|
||||||
|
const rawCanvasRect = canvas.getBoundingClientRect()
|
||||||
|
const wrapperRect = wrapper.getBoundingClientRect()
|
||||||
|
|
||||||
|
return {
|
||||||
|
scaleX: positiveRatio(wrapperRect.width, canvas.offsetWidth),
|
||||||
|
scaleY: positiveRatio(wrapperRect.height, canvas.offsetHeight),
|
||||||
|
rect(element: Element): PhoneViewportRect {
|
||||||
|
return normalizePhoneViewportRect(
|
||||||
|
element.getBoundingClientRect(),
|
||||||
|
rawCanvasRect,
|
||||||
|
wrapperRect,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -140,4 +140,56 @@ describe('springboard widget drag', () => {
|
|||||||
expect(delta.x).toBeCloseTo(100)
|
expect(delta.x).toBeCloseTo(100)
|
||||||
expect(delta.y).toBeCloseTo(200)
|
expect(delta.y).toBeCloseTo(200)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['80% preview', 0.6624],
|
||||||
|
['80% production clamp', 2 / 3],
|
||||||
|
['100%', 0.828],
|
||||||
|
['120%', 0.9936],
|
||||||
|
])('keeps the rendered drag delta 1:1 at %s zoom', (_label, zoom) => {
|
||||||
|
const layoutWidth = 350
|
||||||
|
const layoutHeight = 808
|
||||||
|
const viewportLeft = 128.25
|
||||||
|
const viewportTop = 64.5
|
||||||
|
const viewportWidth = layoutWidth * zoom
|
||||||
|
const viewportHeight = layoutHeight * zoom
|
||||||
|
const pointerStart = {
|
||||||
|
x: viewportLeft + 48.5 * zoom,
|
||||||
|
y: viewportTop + 132.25 * zoom,
|
||||||
|
}
|
||||||
|
const viewportDelta = { x: 73.25, y: -41.75 }
|
||||||
|
const start = springboardViewportToLocal(
|
||||||
|
pointerStart.x,
|
||||||
|
pointerStart.y,
|
||||||
|
viewportLeft,
|
||||||
|
viewportTop,
|
||||||
|
viewportWidth,
|
||||||
|
viewportHeight,
|
||||||
|
layoutWidth,
|
||||||
|
layoutHeight,
|
||||||
|
)
|
||||||
|
const end = springboardViewportToLocal(
|
||||||
|
pointerStart.x + viewportDelta.x,
|
||||||
|
pointerStart.y + viewportDelta.y,
|
||||||
|
viewportLeft,
|
||||||
|
viewportTop,
|
||||||
|
viewportWidth,
|
||||||
|
viewportHeight,
|
||||||
|
layoutWidth,
|
||||||
|
layoutHeight,
|
||||||
|
)
|
||||||
|
const localDelta = springboardViewportDeltaToLocal(
|
||||||
|
viewportDelta.x,
|
||||||
|
viewportDelta.y,
|
||||||
|
viewportWidth,
|
||||||
|
viewportHeight,
|
||||||
|
layoutWidth,
|
||||||
|
layoutHeight,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect((end.x - start.x) * zoom).toBeCloseTo(viewportDelta.x, 6)
|
||||||
|
expect((end.y - start.y) * zoom).toBeCloseTo(viewportDelta.y, 6)
|
||||||
|
expect(localDelta.x * zoom).toBeCloseTo(viewportDelta.x, 6)
|
||||||
|
expect(localDelta.y * zoom).toBeCloseTo(viewportDelta.y, 6)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { readPhoneViewportGeometry } from '@/utils/phoneViewportGeometry'
|
||||||
|
|
||||||
export type PageTurnDirection = -1 | 0 | 1
|
export type PageTurnDirection = -1 | 0 | 1
|
||||||
|
|
||||||
export type SpringboardEdgeTurn = {
|
export type SpringboardEdgeTurn = {
|
||||||
@@ -39,7 +41,9 @@ export function readSpringboardDragMetrics(
|
|||||||
'.springboard-page, .home-folder-panel',
|
'.springboard-page, .home-folder-panel',
|
||||||
)
|
)
|
||||||
if (!surface) return null
|
if (!surface) return null
|
||||||
const bounds = surface.getBoundingClientRect()
|
const bounds =
|
||||||
|
readPhoneViewportGeometry(surface)?.rect(surface) ??
|
||||||
|
surface.getBoundingClientRect()
|
||||||
return {
|
return {
|
||||||
layoutHeight: surface.offsetHeight,
|
layoutHeight: surface.offsetHeight,
|
||||||
layoutWidth: surface.offsetWidth,
|
layoutWidth: surface.offsetWidth,
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ describe('phone tones', () => {
|
|||||||
}> = []
|
}> = []
|
||||||
vi.stubGlobal(
|
vi.stubGlobal(
|
||||||
'Audio',
|
'Audio',
|
||||||
class {
|
class extends EventTarget {
|
||||||
currentTime = 7
|
currentTime = 7
|
||||||
loop = false
|
loop = false
|
||||||
pause = pause
|
pause = pause
|
||||||
@@ -42,6 +42,7 @@ describe('phone tones', () => {
|
|||||||
volume = 0
|
volume = 0
|
||||||
|
|
||||||
constructor(src: string) {
|
constructor(src: string) {
|
||||||
|
super()
|
||||||
this.src = src
|
this.src = src
|
||||||
players.push(this)
|
players.push(this)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { registerPhoneMediaElement } from '@/utils/phoneAudio'
|
||||||
import type { AlarmSoundId } from '@/utils/alarms'
|
import type { AlarmSoundId } from '@/utils/alarms'
|
||||||
import type { NotificationSoundId } from '@/utils/preferences'
|
import type { NotificationSoundId } from '@/utils/preferences'
|
||||||
|
|
||||||
@@ -495,8 +496,8 @@ export function playPhoneVibration(
|
|||||||
kind: PhoneVibrationKind,
|
kind: PhoneVibrationKind,
|
||||||
loop: boolean,
|
loop: boolean,
|
||||||
): () => void {
|
): () => void {
|
||||||
const player = new Audio(
|
const player = registerPhoneMediaElement(
|
||||||
`${import.meta.env.BASE_URL}${VIBRATION_SOUND_PATHS[kind]}`,
|
new Audio(`${import.meta.env.BASE_URL}${VIBRATION_SOUND_PATHS[kind]}`),
|
||||||
)
|
)
|
||||||
let stopped = false
|
let stopped = false
|
||||||
player.loop = loop
|
player.loop = loop
|
||||||
|
|||||||
@@ -19,11 +19,31 @@ const mainCss = readFileSync(
|
|||||||
new URL('../assets/main.css', import.meta.url),
|
new URL('../assets/main.css', import.meta.url),
|
||||||
'utf8',
|
'utf8',
|
||||||
)
|
)
|
||||||
|
const tokensCss = readFileSync(
|
||||||
|
new URL('../ui/tokens.css', import.meta.url),
|
||||||
|
'utf8',
|
||||||
|
)
|
||||||
|
const foundationCss = readFileSync(
|
||||||
|
new URL('../ui/foundation.css', import.meta.url),
|
||||||
|
'utf8',
|
||||||
|
)
|
||||||
const builtInWallpaperCss = mainCss.slice(
|
const builtInWallpaperCss = mainCss.slice(
|
||||||
mainCss.indexOf('.wallpaper--midnight'),
|
mainCss.indexOf('.wallpaper--midnight'),
|
||||||
mainCss.indexOf('.wallpaper--custom'),
|
mainCss.indexOf('.wallpaper--custom'),
|
||||||
)
|
)
|
||||||
|
|
||||||
describe('Springboard page swipe contract', () => {
|
describe('Springboard page swipe contract', () => {
|
||||||
|
it('keeps app and widget labels on the larger shared home typography', () => {
|
||||||
|
expect(tokensCss).toContain('--sky-home-label-font-size: 13px;')
|
||||||
|
expect(tokensCss).toContain('--sky-home-label-height: 16px;')
|
||||||
|
expect(mainCss).toMatch(
|
||||||
|
/\.app-icon-label\s*\{[\s\S]*?font-size:\s*var\(--sky-home-label-font-size\);/,
|
||||||
|
)
|
||||||
|
expect(foundationCss).toMatch(
|
||||||
|
/\.sky-widget-frame__label\s*\{[\s\S]*?font-size:\s*var\(--sky-home-label-font-size\);/,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
it('keeps the built-in wallpapers visually restrained', () => {
|
it('keeps the built-in wallpapers visually restrained', () => {
|
||||||
expect(builtInWallpaperCss).not.toMatch(/(?:conic|repeating-\w+)-gradient/)
|
expect(builtInWallpaperCss).not.toMatch(/(?:conic|repeating-\w+)-gradient/)
|
||||||
expect(builtInWallpaperCss.match(/radial-gradient/g)).toHaveLength(12)
|
expect(builtInWallpaperCss.match(/radial-gradient/g)).toHaveLength(12)
|
||||||
@@ -94,36 +114,99 @@ describe('Springboard page swipe contract', () => {
|
|||||||
expect(folderIconSource).toContain(':style="dragPointerStyle"')
|
expect(folderIconSource).toContain(':style="dragPointerStyle"')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('uses the same scale-aware in-phone drag path as widgets', () => {
|
it('renders the home drag visual through the unzoomed phone portal', () => {
|
||||||
expect(viewSource).not.toContain('source.cloneNode(true)')
|
expect(viewSource).toContain('source.cloneNode(true)')
|
||||||
expect(viewSource).not.toContain('<Teleport to="body">')
|
|
||||||
expect(viewSource).not.toContain(':external-drag-visual')
|
|
||||||
expect(viewSource).toContain(
|
expect(viewSource).toContain(
|
||||||
"'springboard--home-dragging': draggingHomeApp !== null",
|
'<Teleport defer to="#phone-home-drag-portal">',
|
||||||
)
|
)
|
||||||
expect(viewSource).toContain('draggedElement?.getBoundingClientRect()')
|
expect(viewSource).toContain(
|
||||||
expect(appIconSource).not.toContain('externalDragVisual')
|
'<div ref="homeDragLayer" class="home-drag-layer" aria-hidden="true"></div>',
|
||||||
expect(folderIconSource).not.toContain('externalDragVisual')
|
)
|
||||||
expect(mainCss).not.toContain('.home-drag-layer')
|
expect(viewSource).toContain('readPhoneViewportGeometry')
|
||||||
expect(mainCss).not.toContain('.home-drag-ghost')
|
expect(viewSource).toMatch(/const sourceBounds = \w+\.rect\(source\)/)
|
||||||
expect(mainCss).not.toContain('.app-icon-item--drag-source')
|
expect(viewSource).toMatch(/const clipBounds = \w+\.rect\(\w+\)/)
|
||||||
|
expect(viewSource).not.toContain('homeDragLocalPoint')
|
||||||
|
expect(viewSource).not.toContain('springboardViewportToLocal')
|
||||||
|
expect(viewSource).toContain("position.className = 'home-drag-position'")
|
||||||
|
expect(viewSource).toMatch(
|
||||||
|
/homeDragGrip = \{\s*x: event\.clientX - sourceBounds\.left,\s*y: event\.clientY - sourceBounds\.top,/,
|
||||||
|
)
|
||||||
|
expect(viewSource).toMatch(
|
||||||
|
/ghost\.style\.transform = `scale\(\$\{\w+\.scaleX\}, \$\{\w+\.scaleY\}\)`/,
|
||||||
|
)
|
||||||
|
expect(
|
||||||
|
viewSource.match(/:external-drag-visual="homeDragVisualActive"/g),
|
||||||
|
).toHaveLength(4)
|
||||||
|
expect(viewSource).toContain('updateHomeDragGhost(event)')
|
||||||
|
expect(viewSource).toContain('const dropGhost = homeDragGhost')
|
||||||
|
expect(viewSource).toContain('const dropOrigin = homeDragPreviewBounds')
|
||||||
|
expect(viewSource).not.toContain('dropGhost?.getBoundingClientRect()')
|
||||||
|
expect(viewSource).toContain('clearHomeDragGhost(dropGhost)')
|
||||||
|
expect(viewSource).not.toContain('draggedElement?.getBoundingClientRect()')
|
||||||
|
expect(viewSource).not.toContain('springboard--home-dragging')
|
||||||
|
expect(appIconSource).toContain('externalDragVisual?: boolean')
|
||||||
|
expect(folderIconSource).toContain('externalDragVisual?: boolean')
|
||||||
|
expect(appIconSource).toContain('app-icon-item--drag-source')
|
||||||
|
expect(folderIconSource).toContain('app-icon-item--drag-source')
|
||||||
expect(mainCss).toMatch(
|
expect(mainCss).toMatch(
|
||||||
/\.springboard--home-dragging \.springboard-page--apps\s*\{[^}]*overflow:\s*visible;/s,
|
/\.phone-home-drag-portal\s*\{[^}]*pointer-events:\s*none;/s,
|
||||||
)
|
)
|
||||||
|
expect(mainCss).toContain('.home-drag-position')
|
||||||
|
expect(mainCss).toContain('.home-drag-ghost')
|
||||||
|
expect(mainCss).toContain('.app-icon-item--drag-source')
|
||||||
expect(mainCss).toMatch(/\.springboard\s*\{[\s\S]*?overflow:\s*hidden;/)
|
expect(mainCss).toMatch(/\.springboard\s*\{[\s\S]*?overflow:\s*hidden;/)
|
||||||
expect(mainCss).toMatch(
|
expect(mainCss).toMatch(
|
||||||
/\.springboard-page--apps\s*\{[^}]*overflow:\s*hidden;/s,
|
/\.springboard-page--apps\s*\{[^}]*overflow:\s*hidden;/s,
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('cleans up the external drag visual on every terminal path', () => {
|
||||||
|
expect(
|
||||||
|
viewSource.match(
|
||||||
|
/@dragstart="startHomeDrag\('(grid|dock)', [^,]+, \$event\)"/g,
|
||||||
|
),
|
||||||
|
).toHaveLength(4)
|
||||||
|
expect(viewSource.match(/@dragcancel="stopHomeDrag"/g)).toHaveLength(4)
|
||||||
|
expect(viewSource).toContain(
|
||||||
|
'if (expectedGhost && homeDragGhost !== expectedGhost) return',
|
||||||
|
)
|
||||||
|
expect(viewSource).toMatch(
|
||||||
|
/if \(!dragged\) \{\s*clearHomeDragGhost\(\)\s*return/,
|
||||||
|
)
|
||||||
|
expect(viewSource).toMatch(
|
||||||
|
/animateHomeItemDrop\(\s*draggedItem,\s*dropArea,\s*dropIndex,\s*dropOrigin,?\s*\)\.finally\(/,
|
||||||
|
)
|
||||||
|
expect(viewSource).toMatch(
|
||||||
|
/function stopHomeDrag[\s\S]*?clearHomeDragGhost\(\)/,
|
||||||
|
)
|
||||||
|
expect(viewSource).toMatch(
|
||||||
|
/if \(folderId\) \{[\s\S]*?clearHomeDragGhost\(\)/,
|
||||||
|
)
|
||||||
|
expect(viewSource).toMatch(
|
||||||
|
/onBeforeUnmount\(\(\) => \{[\s\S]*?clearHomeDragGhost\(\)/,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
it('targets the active visual page and lets grid or dock drags turn pages', () => {
|
it('targets the active visual page and lets grid or dock drags turn pages', () => {
|
||||||
expect(viewSource).toContain(':data-home-page="page.page"')
|
expect(viewSource).toContain(':data-home-page="page.page"')
|
||||||
expect(viewSource).toContain(':data-home-target-offset="cell.targetOffset"')
|
expect(viewSource).toContain(':data-home-target-offset="cell.targetOffset"')
|
||||||
expect(viewSource).toContain('nearestGridDropTarget')
|
expect(viewSource).toContain('nearestGridDropTarget')
|
||||||
expect(viewSource).toContain('moveHomeAppToGridPage')
|
expect(viewSource).toContain('moveHomeAppToGridPage')
|
||||||
expect(viewSource).not.toContain("draggingHomeApp.value?.area === 'grid'")
|
expect(viewSource).not.toContain("draggingHomeApp.value?.area === 'grid'")
|
||||||
expect(viewSource).toContain('event.clientX < pageBounds.left')
|
expect(viewSource).toContain('event.clientX < springboardBounds.left')
|
||||||
expect(viewSource).toContain('event.clientY > pageBounds.bottom')
|
expect(viewSource).toContain('event.clientY > springboardBounds.bottom')
|
||||||
|
expect(viewSource).toContain('function homeDragViewportRect(')
|
||||||
|
expect(viewSource).toContain('if (geometry) return geometry.rect(element)')
|
||||||
|
expect(viewSource).toContain('homeDragViewportRect(pageElement, geometry)')
|
||||||
|
expect(viewSource).toContain('homeDragViewportRect(springboard, geometry)')
|
||||||
|
expect(viewSource).toContain('homeDragViewportRect(slot, geometry)')
|
||||||
|
expect(viewSource).toContain('homeDragViewportRect(dock, geometry)')
|
||||||
|
expect(viewSource).toMatch(
|
||||||
|
/homeDragViewportRect\(\s*appElement,\s*readPhoneViewportGeometry\(appElement\),?\s*\)/,
|
||||||
|
)
|
||||||
|
expect(viewSource).toContain(
|
||||||
|
'springboardBounds.left + (bounds.left - pageBounds.left)',
|
||||||
|
)
|
||||||
expect(viewSource).toContain('nearestSpringboardRectIndex')
|
expect(viewSource).toContain('nearestSpringboardRectIndex')
|
||||||
expect(viewSource).toContain("queueEdgePageTurn(lastHomePointer, 'app')")
|
expect(viewSource).toContain("queueEdgePageTurn(lastHomePointer, 'app')")
|
||||||
expect(viewSource).toContain("edgePageLocked = dragType === 'widget'")
|
expect(viewSource).toContain("edgePageLocked = dragType === 'widget'")
|
||||||
|
|||||||
@@ -41,6 +41,11 @@ import {
|
|||||||
type HomeItem,
|
type HomeItem,
|
||||||
} from '@/utils/homeLayout'
|
} from '@/utils/homeLayout'
|
||||||
import type { ReorderDirection } from '@/utils/keyboard'
|
import type { ReorderDirection } from '@/utils/keyboard'
|
||||||
|
import {
|
||||||
|
readPhoneViewportGeometry,
|
||||||
|
type PhoneViewportGeometry,
|
||||||
|
type PhoneViewportRect,
|
||||||
|
} from '@/utils/phoneViewportGeometry'
|
||||||
import { layoutSpringboardHomePages } from '@/utils/springboardLayout'
|
import { layoutSpringboardHomePages } from '@/utils/springboardLayout'
|
||||||
import {
|
import {
|
||||||
maximumRenderedWidgetPage,
|
maximumRenderedWidgetPage,
|
||||||
@@ -106,6 +111,8 @@ const draggingHomeApp = ref<{
|
|||||||
area: HomeArea
|
area: HomeArea
|
||||||
index: number
|
index: number
|
||||||
} | null>(null)
|
} | null>(null)
|
||||||
|
const homeDragLayer = ref<HTMLElement | null>(null)
|
||||||
|
const homeDragVisualActive = ref(false)
|
||||||
const temporaryHomePage = ref<number | null>(null)
|
const temporaryHomePage = ref<number | null>(null)
|
||||||
const openedFolderId = ref<string | null>(null)
|
const openedFolderId = ref<string | null>(null)
|
||||||
const folderDraggingOutside = ref(false)
|
const folderDraggingOutside = ref(false)
|
||||||
@@ -125,6 +132,11 @@ let edgePageDirection = 0
|
|||||||
let edgePageLocked = false
|
let edgePageLocked = false
|
||||||
let folderHoverTimer: number | undefined
|
let folderHoverTimer: number | undefined
|
||||||
let lastHomePointer: { clientX: number; clientY: number } | null = null
|
let lastHomePointer: { clientX: number; clientY: number } | null = null
|
||||||
|
let homeDragGhost: HTMLElement | null = null
|
||||||
|
let homeDragGrip: { x: number; y: number } | null = null
|
||||||
|
let homeDragGeometry: PhoneViewportGeometry | null = null
|
||||||
|
let homeDragClipBounds: PhoneViewportRect | null = null
|
||||||
|
let homeDragPreviewBounds: PhoneViewportRect | null = null
|
||||||
|
|
||||||
const installedApps = computed(() =>
|
const installedApps = computed(() =>
|
||||||
PHONE_APPS.filter((app) => appStore.isInstalled(app.id)),
|
PHONE_APPS.filter((app) => appStore.isInstalled(app.id)),
|
||||||
@@ -453,7 +465,7 @@ function startWidgetDrag(id: string, event: PointerEvent): void {
|
|||||||
).find((candidate) => candidate.dataset.widgetId === id)
|
).find((candidate) => candidate.dataset.widgetId === id)
|
||||||
if (!widget) return
|
if (!widget) return
|
||||||
pageTransitioning.value = false
|
pageTransitioning.value = false
|
||||||
const bounds = widget.getBoundingClientRect()
|
const bounds = homeDragViewportRect(widget)
|
||||||
draggingWidgetId.value = id
|
draggingWidgetId.value = id
|
||||||
widgetDragGrip.value = {
|
widgetDragGrip.value = {
|
||||||
x: event.clientX - bounds.left,
|
x: event.clientX - bounds.left,
|
||||||
@@ -474,7 +486,7 @@ function updateWidgetDragPreview(event: {
|
|||||||
)
|
)
|
||||||
const pageElement = grid?.closest<HTMLElement>('.springboard-page')
|
const pageElement = grid?.closest<HTMLElement>('.springboard-page')
|
||||||
if (!grid || !pageElement) return
|
if (!grid || !pageElement) return
|
||||||
const renderedGridBounds = grid.getBoundingClientRect()
|
const renderedGridBounds = homeDragViewportRect(grid)
|
||||||
const gridStyle = getComputedStyle(grid)
|
const gridStyle = getComputedStyle(grid)
|
||||||
const scaleX =
|
const scaleX =
|
||||||
grid.offsetWidth > 0 ? renderedGridBounds.width / grid.offsetWidth : 1
|
grid.offsetWidth > 0 ? renderedGridBounds.width / grid.offsetWidth : 1
|
||||||
@@ -529,7 +541,7 @@ function clearTemporaryHomePage(keepCurrentPage = false): void {
|
|||||||
function resolveHomeEdgeTurn(event: { clientX: number }) {
|
function resolveHomeEdgeTurn(event: { clientX: number }) {
|
||||||
const springboard = document.querySelector<HTMLElement>('.springboard')
|
const springboard = document.querySelector<HTMLElement>('.springboard')
|
||||||
if (!springboard) return null
|
if (!springboard) return null
|
||||||
const bounds = springboard.getBoundingClientRect()
|
const bounds = homeDragViewportRect(springboard)
|
||||||
const renderedLastPage = appPages.value.length
|
const renderedLastPage = appPages.value.length
|
||||||
return resolveSpringboardHomeEdgeTurn(
|
return resolveSpringboardHomeEdgeTurn(
|
||||||
event.clientX,
|
event.clientX,
|
||||||
@@ -548,7 +560,7 @@ function queueEdgePageTurn(
|
|||||||
): void {
|
): void {
|
||||||
const springboard = document.querySelector<HTMLElement>('.springboard')
|
const springboard = document.querySelector<HTMLElement>('.springboard')
|
||||||
if (!springboard) return
|
if (!springboard) return
|
||||||
const bounds = springboard.getBoundingClientRect()
|
const bounds = homeDragViewportRect(springboard)
|
||||||
const turn =
|
const turn =
|
||||||
dragType === 'app'
|
dragType === 'app'
|
||||||
? resolveHomeEdgeTurn(event)
|
? resolveHomeEdgeTurn(event)
|
||||||
@@ -733,10 +745,166 @@ async function saveWidgetConfig(
|
|||||||
if (configured) changePage(configured.page)
|
if (configured) changePage(configured.page)
|
||||||
}
|
}
|
||||||
|
|
||||||
function startHomeDrag(area: HomeArea, index: number): void {
|
function homeDragViewportRect(
|
||||||
|
element: Element,
|
||||||
|
geometry = homeDragGeometry ?? readPhoneViewportGeometry(element),
|
||||||
|
): PhoneViewportRect {
|
||||||
|
if (geometry) return geometry.rect(element)
|
||||||
|
const bounds = element.getBoundingClientRect()
|
||||||
|
return {
|
||||||
|
bottom: bounds.bottom,
|
||||||
|
height: bounds.height,
|
||||||
|
left: bounds.left,
|
||||||
|
right: bounds.right,
|
||||||
|
top: bounds.top,
|
||||||
|
width: bounds.width,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function viewportRectAt(
|
||||||
|
left: number,
|
||||||
|
top: number,
|
||||||
|
width: number,
|
||||||
|
height: number,
|
||||||
|
): PhoneViewportRect {
|
||||||
|
return {
|
||||||
|
bottom: top + height,
|
||||||
|
height,
|
||||||
|
left,
|
||||||
|
right: left + width,
|
||||||
|
top,
|
||||||
|
width,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearHomeDragGhost(expectedGhost?: HTMLElement | null): void {
|
||||||
|
if (expectedGhost && homeDragGhost !== expectedGhost) return
|
||||||
|
homeDragGhost?.remove()
|
||||||
|
homeDragGhost = null
|
||||||
|
homeDragGrip = null
|
||||||
|
homeDragGeometry = null
|
||||||
|
homeDragClipBounds = null
|
||||||
|
homeDragPreviewBounds = null
|
||||||
|
homeDragVisualActive.value = false
|
||||||
|
const layer = homeDragLayer.value
|
||||||
|
layer?.style.removeProperty('left')
|
||||||
|
layer?.style.removeProperty('top')
|
||||||
|
layer?.style.removeProperty('width')
|
||||||
|
layer?.style.removeProperty('height')
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateHomeDragGhost(event: {
|
||||||
|
clientX: number
|
||||||
|
clientY: number
|
||||||
|
}): void {
|
||||||
|
const ghost = homeDragGhost
|
||||||
|
const grip = homeDragGrip
|
||||||
|
const clipBounds = homeDragClipBounds
|
||||||
|
const previewBounds = homeDragPreviewBounds
|
||||||
|
if (!ghost || !grip || !clipBounds || !previewBounds) return
|
||||||
|
|
||||||
|
const left = event.clientX - grip.x
|
||||||
|
const top = event.clientY - grip.y
|
||||||
|
ghost.style.transform = `translate3d(${left - clipBounds.left}px, ${top - clipBounds.top}px, 0)`
|
||||||
|
homeDragPreviewBounds = viewportRectAt(
|
||||||
|
left,
|
||||||
|
top,
|
||||||
|
previewBounds.width,
|
||||||
|
previewBounds.height,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function createHomeDragGhost(event: PointerEvent): void {
|
||||||
|
clearHomeDragGhost()
|
||||||
|
const eventTarget =
|
||||||
|
event.currentTarget instanceof Element
|
||||||
|
? event.currentTarget
|
||||||
|
: event.target instanceof Element
|
||||||
|
? event.target
|
||||||
|
: null
|
||||||
|
const source = eventTarget?.closest<HTMLElement>('.app-icon-item')
|
||||||
|
const layer = homeDragLayer.value
|
||||||
|
const springboard = source?.closest<HTMLElement>('.springboard')
|
||||||
|
const portal = layer?.closest<HTMLElement>('.phone-home-drag-portal')
|
||||||
|
const geometry = readPhoneViewportGeometry(source ?? null)
|
||||||
|
if (!source || !layer || !springboard || !portal || !geometry) return
|
||||||
|
|
||||||
|
const portalBounds = portal.getBoundingClientRect()
|
||||||
|
const clipBounds = geometry.rect(springboard)
|
||||||
|
const sourceBounds = geometry.rect(source)
|
||||||
|
if (
|
||||||
|
clipBounds.width <= 0 ||
|
||||||
|
clipBounds.height <= 0 ||
|
||||||
|
sourceBounds.width <= 0 ||
|
||||||
|
sourceBounds.height <= 0
|
||||||
|
) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
layer.style.left = `${clipBounds.left - portalBounds.left}px`
|
||||||
|
layer.style.top = `${clipBounds.top - portalBounds.top}px`
|
||||||
|
layer.style.width = `${clipBounds.width}px`
|
||||||
|
layer.style.height = `${clipBounds.height}px`
|
||||||
|
|
||||||
|
const ghost = source.cloneNode(true) as HTMLElement
|
||||||
|
ghost.classList.remove(
|
||||||
|
'app-icon-item--dragging',
|
||||||
|
'app-icon-item--editing',
|
||||||
|
'app-icon-item--drag-source',
|
||||||
|
'app-icon-item--drop-settling',
|
||||||
|
'home-folder-item--dragging',
|
||||||
|
'home-item--folder-hover',
|
||||||
|
)
|
||||||
|
ghost.classList.add('home-drag-ghost')
|
||||||
|
ghost.removeAttribute('style')
|
||||||
|
ghost
|
||||||
|
.querySelector<HTMLElement>('.app-icon-button')
|
||||||
|
?.style.removeProperty('transform')
|
||||||
|
ghost.setAttribute('aria-hidden', 'true')
|
||||||
|
ghost.querySelector('.app-icon-remove')?.remove()
|
||||||
|
for (const element of [ghost, ...Array.from(ghost.querySelectorAll('*'))]) {
|
||||||
|
element.removeAttribute('id')
|
||||||
|
for (const attribute of element.getAttributeNames()) {
|
||||||
|
if (attribute.startsWith('data-home-')) element.removeAttribute(attribute)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const control of ghost.querySelectorAll<HTMLElement>(
|
||||||
|
'a, button, input, select, textarea, [tabindex]',
|
||||||
|
)) {
|
||||||
|
control.tabIndex = -1
|
||||||
|
}
|
||||||
|
|
||||||
|
const position = document.createElement('div')
|
||||||
|
position.className = 'home-drag-position'
|
||||||
|
position.style.width = `${sourceBounds.width}px`
|
||||||
|
position.style.height = `${sourceBounds.height}px`
|
||||||
|
ghost.style.width = `${source.offsetWidth}px`
|
||||||
|
ghost.style.height = `${source.offsetHeight}px`
|
||||||
|
ghost.style.transform = `scale(${geometry.scaleX}, ${geometry.scaleY})`
|
||||||
|
position.appendChild(ghost)
|
||||||
|
|
||||||
|
homeDragGhost = position
|
||||||
|
homeDragGrip = {
|
||||||
|
x: event.clientX - sourceBounds.left,
|
||||||
|
y: event.clientY - sourceBounds.top,
|
||||||
|
}
|
||||||
|
homeDragGeometry = geometry
|
||||||
|
homeDragClipBounds = clipBounds
|
||||||
|
homeDragPreviewBounds = sourceBounds
|
||||||
|
layer.appendChild(position)
|
||||||
|
homeDragVisualActive.value = true
|
||||||
|
updateHomeDragGhost(event)
|
||||||
|
}
|
||||||
|
|
||||||
|
function startHomeDrag(
|
||||||
|
area: HomeArea,
|
||||||
|
index: number,
|
||||||
|
event: PointerEvent,
|
||||||
|
): void {
|
||||||
clearTemporaryHomePage()
|
clearTemporaryHomePage()
|
||||||
lastHomePointer = null
|
lastHomePointer = null
|
||||||
draggingHomeApp.value = { area, index }
|
draggingHomeApp.value = { area, index }
|
||||||
|
createHomeDragGhost(event)
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearFolderHover(): void {
|
function clearFolderHover(): void {
|
||||||
@@ -754,10 +922,40 @@ function queueFolderHover(event: PointerEvent): void {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const targetElement = document
|
const targetElement = Array.from(
|
||||||
.elementsFromPoint(event.clientX, event.clientY)
|
document.querySelectorAll<HTMLElement>('[data-home-area][data-home-index]'),
|
||||||
.map((element) => element.closest<HTMLElement>('[data-home-index]'))
|
)
|
||||||
.find((element) => element && !element.closest('.app-icon-item--dragging'))
|
.filter((element) => {
|
||||||
|
const area = element.dataset.homeArea as HomeArea | undefined
|
||||||
|
const index = Number(element.dataset.homeIndex)
|
||||||
|
if (
|
||||||
|
(area !== 'grid' && area !== 'dock') ||
|
||||||
|
!Number.isInteger(index) ||
|
||||||
|
(area === dragged.area && index === dragged.index)
|
||||||
|
) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
area === 'grid' &&
|
||||||
|
Number(
|
||||||
|
element.closest<HTMLElement>('[data-home-page]')?.dataset.homePage,
|
||||||
|
) !== phone.currentPage
|
||||||
|
) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
const bounds = homeDragViewportRect(element)
|
||||||
|
return (
|
||||||
|
event.clientX >= bounds.left &&
|
||||||
|
event.clientX <= bounds.right &&
|
||||||
|
event.clientY >= bounds.top &&
|
||||||
|
event.clientY <= bounds.bottom
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.sort((left, right) => {
|
||||||
|
const leftIsDock = left.dataset.homeArea === 'dock'
|
||||||
|
const rightIsDock = right.dataset.homeArea === 'dock'
|
||||||
|
return Number(rightIsDock) - Number(leftIsDock)
|
||||||
|
})[0]
|
||||||
const area = targetElement?.dataset.homeArea as HomeArea | undefined
|
const area = targetElement?.dataset.homeArea as HomeArea | undefined
|
||||||
const targetIndex = Number(targetElement?.dataset.homeIndex)
|
const targetIndex = Number(targetElement?.dataset.homeIndex)
|
||||||
if (
|
if (
|
||||||
@@ -822,6 +1020,7 @@ function queueFolderHover(event: PointerEvent): void {
|
|||||||
if (folderId) {
|
if (folderId) {
|
||||||
draggingHomeApp.value = null
|
draggingHomeApp.value = null
|
||||||
lastHomePointer = null
|
lastHomePointer = null
|
||||||
|
clearHomeDragGhost()
|
||||||
clearTemporaryHomePage()
|
clearTemporaryHomePage()
|
||||||
openedFolderId.value = folderId
|
openedFolderId.value = folderId
|
||||||
}
|
}
|
||||||
@@ -832,6 +1031,7 @@ function queueFolderHover(event: PointerEvent): void {
|
|||||||
function moveHomeDrag(event: PointerEvent): void {
|
function moveHomeDrag(event: PointerEvent): void {
|
||||||
if (!draggingHomeApp.value) return
|
if (!draggingHomeApp.value) return
|
||||||
lastHomePointer = { clientX: event.clientX, clientY: event.clientY }
|
lastHomePointer = { clientX: event.clientX, clientY: event.clientY }
|
||||||
|
updateHomeDragGhost(event)
|
||||||
if (resolveHomeEdgeTurn(event)) {
|
if (resolveHomeEdgeTurn(event)) {
|
||||||
clearFolderHover()
|
clearFolderHover()
|
||||||
queueEdgePageTurn(event, 'app')
|
queueEdgePageTurn(event, 'app')
|
||||||
@@ -845,7 +1045,7 @@ async function animateHomeItemDrop(
|
|||||||
item: HomeItem,
|
item: HomeItem,
|
||||||
area: HomeArea,
|
area: HomeArea,
|
||||||
index: number,
|
index: number,
|
||||||
from: DOMRect,
|
from: PhoneViewportRect,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
await nextTick()
|
await nextTick()
|
||||||
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return
|
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return
|
||||||
@@ -861,7 +1061,10 @@ async function animateHomeItemDrop(
|
|||||||
)
|
)
|
||||||
if (!appElement) return
|
if (!appElement) return
|
||||||
|
|
||||||
const target = appElement.getBoundingClientRect()
|
const target = homeDragViewportRect(
|
||||||
|
appElement,
|
||||||
|
readPhoneViewportGeometry(appElement),
|
||||||
|
)
|
||||||
const { x: offsetX, y: offsetY } = springboardViewportDeltaToLocal(
|
const { x: offsetX, y: offsetY } = springboardViewportDeltaToLocal(
|
||||||
from.left - target.left,
|
from.left - target.left,
|
||||||
from.top - target.top,
|
from.top - target.top,
|
||||||
@@ -924,13 +1127,16 @@ function nearestGridDropTarget(event: {
|
|||||||
`.springboard-page--apps[data-home-page="${page}"] .app-grid`,
|
`.springboard-page--apps[data-home-page="${page}"] .app-grid`,
|
||||||
)
|
)
|
||||||
const pageElement = grid?.closest<HTMLElement>('.springboard-page')
|
const pageElement = grid?.closest<HTMLElement>('.springboard-page')
|
||||||
if (!grid || !pageElement) return null
|
const springboard = grid?.closest<HTMLElement>('.springboard')
|
||||||
const pageBounds = pageElement.getBoundingClientRect()
|
if (!grid || !pageElement || !springboard) return null
|
||||||
|
const geometry = homeDragGeometry ?? readPhoneViewportGeometry(springboard)
|
||||||
|
const pageBounds = homeDragViewportRect(pageElement, geometry)
|
||||||
|
const springboardBounds = homeDragViewportRect(springboard, geometry)
|
||||||
if (
|
if (
|
||||||
event.clientX < pageBounds.left ||
|
event.clientX < springboardBounds.left ||
|
||||||
event.clientX > pageBounds.right ||
|
event.clientX > springboardBounds.right ||
|
||||||
event.clientY < pageBounds.top ||
|
event.clientY < springboardBounds.top ||
|
||||||
event.clientY > pageBounds.bottom
|
event.clientY > springboardBounds.bottom
|
||||||
) {
|
) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
@@ -940,7 +1146,15 @@ function nearestGridDropTarget(event: {
|
|||||||
const closestIndex = nearestSpringboardRectIndex(
|
const closestIndex = nearestSpringboardRectIndex(
|
||||||
event.clientX,
|
event.clientX,
|
||||||
event.clientY,
|
event.clientY,
|
||||||
slots.map((slot) => slot.getBoundingClientRect()),
|
slots.map((slot) => {
|
||||||
|
const bounds = homeDragViewportRect(slot, geometry)
|
||||||
|
return {
|
||||||
|
height: bounds.height,
|
||||||
|
left: springboardBounds.left + (bounds.left - pageBounds.left),
|
||||||
|
top: springboardBounds.top + (bounds.top - pageBounds.top),
|
||||||
|
width: bounds.width,
|
||||||
|
}
|
||||||
|
}),
|
||||||
)
|
)
|
||||||
const closest = closestIndex === null ? null : slots[closestIndex]
|
const closest = closestIndex === null ? null : slots[closestIndex]
|
||||||
const targetOffset = Number(closest?.dataset.homeTargetOffset)
|
const targetOffset = Number(closest?.dataset.homeTargetOffset)
|
||||||
@@ -953,7 +1167,8 @@ function nearestDockDropTarget(event: {
|
|||||||
}): HTMLElement | null {
|
}): HTMLElement | null {
|
||||||
const dock = document.querySelector<HTMLElement>('.app-dock')
|
const dock = document.querySelector<HTMLElement>('.app-dock')
|
||||||
if (!dock) return null
|
if (!dock) return null
|
||||||
const dockBounds = dock.getBoundingClientRect()
|
const geometry = homeDragGeometry ?? readPhoneViewportGeometry(dock)
|
||||||
|
const dockBounds = homeDragViewportRect(dock, geometry)
|
||||||
if (
|
if (
|
||||||
event.clientX < dockBounds.left ||
|
event.clientX < dockBounds.left ||
|
||||||
event.clientX > dockBounds.right ||
|
event.clientX > dockBounds.right ||
|
||||||
@@ -970,7 +1185,7 @@ function nearestDockDropTarget(event: {
|
|||||||
const closestIndex = nearestSpringboardRectIndex(
|
const closestIndex = nearestSpringboardRectIndex(
|
||||||
event.clientX,
|
event.clientX,
|
||||||
event.clientY,
|
event.clientY,
|
||||||
slots.map((slot) => slot.getBoundingClientRect()),
|
slots.map((slot) => homeDragViewportRect(slot, geometry)),
|
||||||
)
|
)
|
||||||
return closestIndex === null ? null : slots[closestIndex]
|
return closestIndex === null ? null : slots[closestIndex]
|
||||||
}
|
}
|
||||||
@@ -979,12 +1194,14 @@ function finishHomeDrag(event: PointerEvent): void {
|
|||||||
clearEdgePageTurn()
|
clearEdgePageTurn()
|
||||||
clearFolderHover()
|
clearFolderHover()
|
||||||
const dragged = draggingHomeApp.value
|
const dragged = draggingHomeApp.value
|
||||||
if (!dragged) return
|
if (!dragged) {
|
||||||
|
clearHomeDragGhost()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
updateHomeDragGhost(event)
|
||||||
const draggedItem = appStore.homeLayout[dragged.area][dragged.index]
|
const draggedItem = appStore.homeLayout[dragged.area][dragged.index]
|
||||||
const draggedElement = document.querySelector<HTMLElement>(
|
const dropGhost = homeDragGhost
|
||||||
`[data-home-area="${dragged.area}"][data-home-index="${dragged.index}"]`,
|
const dropOrigin = homeDragPreviewBounds ? { ...homeDragPreviewBounds } : null
|
||||||
)
|
|
||||||
const dropOrigin = draggedElement?.getBoundingClientRect()
|
|
||||||
const dockTarget = nearestDockDropTarget(event)
|
const dockTarget = nearestDockDropTarget(event)
|
||||||
const gridTarget = dockTarget ? null : nearestGridDropTarget(event)
|
const gridTarget = dockTarget ? null : nearestGridDropTarget(event)
|
||||||
let dropArea = dragged.area
|
let dropArea = dragged.area
|
||||||
@@ -1022,7 +1239,14 @@ function finishHomeDrag(event: PointerEvent): void {
|
|||||||
lastHomePointer = null
|
lastHomePointer = null
|
||||||
clearTemporaryHomePage(moved && gridTarget !== null)
|
clearTemporaryHomePage(moved && gridTarget !== null)
|
||||||
if (moved && draggedItem && dropOrigin) {
|
if (moved && draggedItem && dropOrigin) {
|
||||||
void animateHomeItemDrop(draggedItem, dropArea, dropIndex, dropOrigin)
|
void animateHomeItemDrop(
|
||||||
|
draggedItem,
|
||||||
|
dropArea,
|
||||||
|
dropIndex,
|
||||||
|
dropOrigin,
|
||||||
|
).finally(() => clearHomeDragGhost(dropGhost))
|
||||||
|
} else {
|
||||||
|
clearHomeDragGhost()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1031,6 +1255,7 @@ function stopHomeDrag(): void {
|
|||||||
clearFolderHover()
|
clearFolderHover()
|
||||||
draggingHomeApp.value = null
|
draggingHomeApp.value = null
|
||||||
lastHomePointer = null
|
lastHomePointer = null
|
||||||
|
clearHomeDragGhost()
|
||||||
clearTemporaryHomePage()
|
clearTemporaryHomePage()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1043,7 +1268,9 @@ function reorderHomeApp(
|
|||||||
const appElement = document.querySelector<HTMLElement>(
|
const appElement = document.querySelector<HTMLElement>(
|
||||||
`[data-home-area="${area}"][data-home-index="${sourceIndex}"]`,
|
`[data-home-area="${area}"][data-home-index="${sourceIndex}"]`,
|
||||||
)
|
)
|
||||||
const moveOrigin = appElement?.getBoundingClientRect()
|
const moveOrigin = appElement
|
||||||
|
? homeDragViewportRect(appElement, readPhoneViewportGeometry(appElement))
|
||||||
|
: null
|
||||||
const targetIndex = homeKeyboardTarget(
|
const targetIndex = homeKeyboardTarget(
|
||||||
appStore.homeLayout,
|
appStore.homeLayout,
|
||||||
area,
|
area,
|
||||||
@@ -1131,7 +1358,10 @@ function extractOpenedFolderApp(
|
|||||||
`.home-folder-panel [data-folder-app-index="${sourceIndex}"]`,
|
`.home-folder-panel [data-folder-app-index="${sourceIndex}"]`,
|
||||||
)
|
)
|
||||||
?.closest<HTMLElement>('.app-icon-item')
|
?.closest<HTMLElement>('.app-icon-item')
|
||||||
const sourceBounds = sourceElement?.getBoundingClientRect()
|
const geometry = readPhoneViewportGeometry(sourceElement ?? null)
|
||||||
|
const sourceBounds = sourceElement
|
||||||
|
? homeDragViewportRect(sourceElement, geometry)
|
||||||
|
: null
|
||||||
const candidates = Array.from(
|
const candidates = Array.from(
|
||||||
document.querySelectorAll<HTMLElement>('[data-home-index][data-home-area]'),
|
document.querySelectorAll<HTMLElement>('[data-home-index][data-home-area]'),
|
||||||
).filter((element) => {
|
).filter((element) => {
|
||||||
@@ -1145,8 +1375,8 @@ function extractOpenedFolderApp(
|
|||||||
})
|
})
|
||||||
const target = candidates.reduce<HTMLElement | null>((closest, candidate) => {
|
const target = candidates.reduce<HTMLElement | null>((closest, candidate) => {
|
||||||
if (!closest) return candidate
|
if (!closest) return candidate
|
||||||
const bounds = candidate.getBoundingClientRect()
|
const bounds = homeDragViewportRect(candidate, geometry)
|
||||||
const closestBounds = closest.getBoundingClientRect()
|
const closestBounds = homeDragViewportRect(closest, geometry)
|
||||||
const distance = Math.hypot(
|
const distance = Math.hypot(
|
||||||
event.clientX - (bounds.left + bounds.width / 2),
|
event.clientX - (bounds.left + bounds.width / 2),
|
||||||
event.clientY - (bounds.top + bounds.height / 2),
|
event.clientY - (bounds.top + bounds.height / 2),
|
||||||
@@ -1212,6 +1442,7 @@ onBeforeUnmount(() => {
|
|||||||
pendingWidgetPointer = null
|
pendingWidgetPointer = null
|
||||||
lastWidgetPointer = null
|
lastWidgetPointer = null
|
||||||
lastHomePointer = null
|
lastHomePointer = null
|
||||||
|
clearHomeDragGhost()
|
||||||
temporaryHomePage.value = null
|
temporaryHomePage.value = null
|
||||||
draggingHomeApp.value = null
|
draggingHomeApp.value = null
|
||||||
draggingWidgetId.value = null
|
draggingWidgetId.value = null
|
||||||
@@ -1227,7 +1458,6 @@ onBeforeUnmount(() => {
|
|||||||
'springboard--dragging': dragging,
|
'springboard--dragging': dragging,
|
||||||
'springboard--editing': editMode,
|
'springboard--editing': editMode,
|
||||||
'springboard--folder-open': folderOverlayVisible,
|
'springboard--folder-open': folderOverlayVisible,
|
||||||
'springboard--home-dragging': draggingHomeApp !== null,
|
|
||||||
'springboard--widget-dragging': draggingWidgetId !== null,
|
'springboard--widget-dragging': draggingWidgetId !== null,
|
||||||
},
|
},
|
||||||
]"
|
]"
|
||||||
@@ -1303,6 +1533,7 @@ onBeforeUnmount(() => {
|
|||||||
:data-home-index="cell.sourceIndex"
|
:data-home-index="cell.sourceIndex"
|
||||||
:data-home-target-offset="cell.targetOffset"
|
:data-home-target-offset="cell.targetOffset"
|
||||||
:edit-mode="editMode"
|
:edit-mode="editMode"
|
||||||
|
:external-drag-visual="homeDragVisualActive"
|
||||||
:class="{
|
:class="{
|
||||||
'home-item--folder-hover':
|
'home-item--folder-hover':
|
||||||
folderHoverTarget === `grid:${cell.sourceIndex}`,
|
folderHoverTarget === `grid:${cell.sourceIndex}`,
|
||||||
@@ -1310,7 +1541,7 @@ onBeforeUnmount(() => {
|
|||||||
@dragcancel="stopHomeDrag"
|
@dragcancel="stopHomeDrag"
|
||||||
@dragend="finishHomeDrag"
|
@dragend="finishHomeDrag"
|
||||||
@dragmove="moveHomeDrag"
|
@dragmove="moveHomeDrag"
|
||||||
@dragstart="startHomeDrag('grid', cell.sourceIndex)"
|
@dragstart="startHomeDrag('grid', cell.sourceIndex, $event)"
|
||||||
@edit="enterEditMode"
|
@edit="enterEditMode"
|
||||||
@remove="removeHomeApp(cell.app.id)"
|
@remove="removeHomeApp(cell.app.id)"
|
||||||
@reorder="reorderHomeApp('grid', cell.sourceIndex, $event)"
|
@reorder="reorderHomeApp('grid', cell.sourceIndex, $event)"
|
||||||
@@ -1325,6 +1556,7 @@ onBeforeUnmount(() => {
|
|||||||
:data-home-index="cell.sourceIndex"
|
:data-home-index="cell.sourceIndex"
|
||||||
:data-home-target-offset="cell.targetOffset"
|
:data-home-target-offset="cell.targetOffset"
|
||||||
:edit-mode="editMode"
|
:edit-mode="editMode"
|
||||||
|
:external-drag-visual="homeDragVisualActive"
|
||||||
:folder="cell.folder"
|
:folder="cell.folder"
|
||||||
:class="{
|
:class="{
|
||||||
'home-item--folder-hover':
|
'home-item--folder-hover':
|
||||||
@@ -1333,7 +1565,7 @@ onBeforeUnmount(() => {
|
|||||||
@dragcancel="stopHomeDrag"
|
@dragcancel="stopHomeDrag"
|
||||||
@dragend="finishHomeDrag"
|
@dragend="finishHomeDrag"
|
||||||
@dragmove="moveHomeDrag"
|
@dragmove="moveHomeDrag"
|
||||||
@dragstart="startHomeDrag('grid', cell.sourceIndex)"
|
@dragstart="startHomeDrag('grid', cell.sourceIndex, $event)"
|
||||||
@edit="enterEditMode"
|
@edit="enterEditMode"
|
||||||
@open="openFolder(cell.folder.id)"
|
@open="openFolder(cell.folder.id)"
|
||||||
@reorder="reorderHomeApp('grid', cell.sourceIndex, $event)"
|
@reorder="reorderHomeApp('grid', cell.sourceIndex, $event)"
|
||||||
@@ -1484,6 +1716,10 @@ onBeforeUnmount(() => {
|
|||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<Teleport defer to="#phone-home-drag-portal">
|
||||||
|
<div ref="homeDragLayer" class="home-drag-layer" aria-hidden="true"></div>
|
||||||
|
</Teleport>
|
||||||
|
|
||||||
<Transition name="edit-done">
|
<Transition name="edit-done">
|
||||||
<k-glass
|
<k-glass
|
||||||
v-if="editMode && isEditablePage && !folderOverlayVisible"
|
v-if="editMode && isEditablePage && !folderOverlayVisible"
|
||||||
@@ -1530,6 +1766,7 @@ onBeforeUnmount(() => {
|
|||||||
:data-home-item-key="`app-${slot.app.id}`"
|
:data-home-item-key="`app-${slot.app.id}`"
|
||||||
:data-home-index="slot.index"
|
:data-home-index="slot.index"
|
||||||
:edit-mode="editMode"
|
:edit-mode="editMode"
|
||||||
|
:external-drag-visual="homeDragVisualActive"
|
||||||
:show-label="false"
|
:show-label="false"
|
||||||
:class="{
|
:class="{
|
||||||
'home-item--folder-hover':
|
'home-item--folder-hover':
|
||||||
@@ -1538,7 +1775,7 @@ onBeforeUnmount(() => {
|
|||||||
@dragcancel="stopHomeDrag"
|
@dragcancel="stopHomeDrag"
|
||||||
@dragend="finishHomeDrag"
|
@dragend="finishHomeDrag"
|
||||||
@dragmove="moveHomeDrag"
|
@dragmove="moveHomeDrag"
|
||||||
@dragstart="startHomeDrag('dock', slot.index)"
|
@dragstart="startHomeDrag('dock', slot.index, $event)"
|
||||||
@edit="enterEditMode"
|
@edit="enterEditMode"
|
||||||
@remove="removeHomeApp(slot.app.id)"
|
@remove="removeHomeApp(slot.app.id)"
|
||||||
@reorder="reorderHomeApp('dock', slot.index, $event)"
|
@reorder="reorderHomeApp('dock', slot.index, $event)"
|
||||||
@@ -1552,6 +1789,7 @@ onBeforeUnmount(() => {
|
|||||||
:data-home-item-key="slot.folder.id"
|
:data-home-item-key="slot.folder.id"
|
||||||
:data-home-index="slot.index"
|
:data-home-index="slot.index"
|
||||||
:edit-mode="editMode"
|
:edit-mode="editMode"
|
||||||
|
:external-drag-visual="homeDragVisualActive"
|
||||||
:folder="slot.folder"
|
:folder="slot.folder"
|
||||||
:show-label="false"
|
:show-label="false"
|
||||||
:class="{
|
:class="{
|
||||||
@@ -1561,7 +1799,7 @@ onBeforeUnmount(() => {
|
|||||||
@dragcancel="stopHomeDrag"
|
@dragcancel="stopHomeDrag"
|
||||||
@dragend="finishHomeDrag"
|
@dragend="finishHomeDrag"
|
||||||
@dragmove="moveHomeDrag"
|
@dragmove="moveHomeDrag"
|
||||||
@dragstart="startHomeDrag('dock', slot.index)"
|
@dragstart="startHomeDrag('dock', slot.index, $event)"
|
||||||
@edit="enterEditMode"
|
@edit="enterEditMode"
|
||||||
@open="openFolder(slot.folder.id)"
|
@open="openFolder(slot.folder.id)"
|
||||||
@reorder="reorderHomeApp('dock', slot.index, $event)"
|
@reorder="reorderHomeApp('dock', slot.index, $event)"
|
||||||
|
|||||||
@@ -791,7 +791,7 @@ onBeforeUnmount(() => {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
color: #f6f7f9;
|
color: #f6f7f9;
|
||||||
background: #07090c;
|
background: #07090c;
|
||||||
font-family: Inter, system-ui, sans-serif;
|
font-family: var(--sky-font-family);
|
||||||
}
|
}
|
||||||
.billing-app--light {
|
.billing-app--light {
|
||||||
--billing-border: rgb(15 23 42 / 10%);
|
--billing-border: rgb(15 23 42 / 10%);
|
||||||
|
|||||||
@@ -1156,13 +1156,7 @@ onMounted(async () => {
|
|||||||
padding: 47px 0 24px;
|
padding: 47px 0 24px;
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
color: var(--label);
|
color: var(--label);
|
||||||
font-family:
|
font-family: var(--sky-font-family);
|
||||||
Inter,
|
|
||||||
-apple-system,
|
|
||||||
BlinkMacSystemFont,
|
|
||||||
'SF Pro Display',
|
|
||||||
system-ui,
|
|
||||||
sans-serif;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.calendar--light {
|
.calendar--light {
|
||||||
|
|||||||
@@ -134,10 +134,30 @@ async function requestPhoto(): Promise<void> {
|
|||||||
window.setTimeout(() => void completeDevelopmentCapture(id, 'photo'), 700)
|
window.setTimeout(() => void completeDevelopmentCapture(id, 'photo'), 700)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
await nuiCall('media:requestUpload', {
|
console.info('[Sky Phone Media] Camera requested a photo upload.', {
|
||||||
|
correlationId: id,
|
||||||
|
})
|
||||||
|
const response = await nuiCall('media:requestUpload', {
|
||||||
correlationId: id,
|
correlationId: id,
|
||||||
mediaType: 'photo',
|
mediaType: 'photo',
|
||||||
})
|
})
|
||||||
|
if (!response.success) {
|
||||||
|
console.error('[Sky Phone Media] Photo upload request was rejected.', {
|
||||||
|
correlationId: id,
|
||||||
|
error: response.error,
|
||||||
|
})
|
||||||
|
window.postMessage(
|
||||||
|
{
|
||||||
|
data: {
|
||||||
|
correlationId: id,
|
||||||
|
error: response.error ?? 'request_failed',
|
||||||
|
success: false,
|
||||||
|
},
|
||||||
|
type: 'media:uploadResult',
|
||||||
|
},
|
||||||
|
'*',
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function startRecording(): void {
|
function startRecording(): void {
|
||||||
@@ -374,6 +394,11 @@ function onMessage(event: MessageEvent): void {
|
|||||||
} else if (message.type === 'media:uploadResult') {
|
} else if (message.type === 'media:uploadResult') {
|
||||||
const result = message.data as UploadResult
|
const result = message.data as UploadResult
|
||||||
if (!result?.correlationId) return
|
if (!result?.correlationId) return
|
||||||
|
console.info('[Sky Phone Media] Camera received an upload result.', {
|
||||||
|
correlationId: result.correlationId,
|
||||||
|
error: result.error,
|
||||||
|
success: result.success,
|
||||||
|
})
|
||||||
savingVideo.value = false
|
savingVideo.value = false
|
||||||
if (result.success && result.media) {
|
if (result.success && result.media) {
|
||||||
latestMedia.value = result.media
|
latestMedia.value = result.media
|
||||||
|
|||||||
@@ -2195,7 +2195,7 @@ onMounted(async () => {
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: #151613;
|
background: #151613;
|
||||||
color: #f8f8f4;
|
color: #f8f8f4;
|
||||||
font-family: Inter, system-ui, sans-serif;
|
font-family: var(--sky-font-family);
|
||||||
}
|
}
|
||||||
.citymarkt--light {
|
.citymarkt--light {
|
||||||
--ink: #fff;
|
--ink: #fff;
|
||||||
|
|||||||
@@ -3586,7 +3586,7 @@ onMounted(async () => {
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: #12171b !important;
|
background: #12171b !important;
|
||||||
color: #f7f8f4;
|
color: #f7f8f4;
|
||||||
font-family: Inter, system-ui, sans-serif;
|
font-family: var(--sky-font-family);
|
||||||
}
|
}
|
||||||
.feather-app--active.feather-app--light {
|
.feather-app--active.feather-app--light {
|
||||||
--feather-panel: #f0f1ec;
|
--feather-panel: #f0f1ec;
|
||||||
|
|||||||
@@ -77,6 +77,10 @@ import {
|
|||||||
SkyToggle,
|
SkyToggle,
|
||||||
} from '@/ui'
|
} from '@/ui'
|
||||||
import { nuiCall } from '@/utils/nui'
|
import { nuiCall } from '@/utils/nui'
|
||||||
|
import {
|
||||||
|
getPhoneOutputVolume,
|
||||||
|
subscribePhoneOutputVolume,
|
||||||
|
} from '@/utils/phoneAudio'
|
||||||
|
|
||||||
type Tab = 'feed' | 'discover' | 'create' | 'activity' | 'profile'
|
type Tab = 'feed' | 'discover' | 'create' | 'activity' | 'profile'
|
||||||
type AuthMode = 'login' | 'register'
|
type AuthMode = 'login' | 'register'
|
||||||
@@ -194,6 +198,8 @@ let flipTokYoutubePlayer: YouTubePlayer | null = null
|
|||||||
let flipTokYoutubeApi: YouTubeApi | null = null
|
let flipTokYoutubeApi: YouTubeApi | null = null
|
||||||
let flipTokYoutubeOwner = ''
|
let flipTokYoutubeOwner = ''
|
||||||
let flipTokYoutubeVideoId = ''
|
let flipTokYoutubeVideoId = ''
|
||||||
|
let flipTokYoutubeVolume = 0
|
||||||
|
let removePhoneOutputVolumeListener: (() => void) | undefined
|
||||||
let observer: IntersectionObserver | null = null
|
let observer: IntersectionObserver | null = null
|
||||||
let videoClickTimer: number | null = null
|
let videoClickTimer: number | null = null
|
||||||
let likePulseTimer: number | null = null
|
let likePulseTimer: number | null = null
|
||||||
@@ -786,6 +792,7 @@ async function playFlipTokYoutube(
|
|||||||
seconds = 0,
|
seconds = 0,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
flipTokYoutubeOwner = owner
|
flipTokYoutubeOwner = owner
|
||||||
|
flipTokYoutubeVolume = volume
|
||||||
try {
|
try {
|
||||||
const api = await loadYouTubeApi()
|
const api = await loadYouTubeApi()
|
||||||
flipTokYoutubeApi = api
|
flipTokYoutubeApi = api
|
||||||
@@ -794,7 +801,7 @@ async function playFlipTokYoutube(
|
|||||||
flipTokYoutubeVideoId = videoId
|
flipTokYoutubeVideoId = videoId
|
||||||
flipTokYoutubePlayer.loadVideoById(videoId)
|
flipTokYoutubePlayer.loadVideoById(videoId)
|
||||||
}
|
}
|
||||||
flipTokYoutubePlayer.setVolume(volume)
|
flipTokYoutubePlayer.setVolume(volume * getPhoneOutputVolume())
|
||||||
flipTokYoutubePlayer.seekTo(Math.max(0, seconds), true)
|
flipTokYoutubePlayer.seekTo(Math.max(0, seconds), true)
|
||||||
flipTokYoutubePlayer.playVideo()
|
flipTokYoutubePlayer.playVideo()
|
||||||
if (owner === 'composer') customMusicLoadFailed.value = false
|
if (owner === 'composer') customMusicLoadFailed.value = false
|
||||||
@@ -835,7 +842,7 @@ async function playFlipTokYoutube(
|
|||||||
},
|
},
|
||||||
onReady: (event) => {
|
onReady: (event) => {
|
||||||
flipTokYoutubePlayer = event.target
|
flipTokYoutubePlayer = event.target
|
||||||
event.target.setVolume(volume)
|
event.target.setVolume(volume * getPhoneOutputVolume())
|
||||||
event.target.seekTo(Math.max(0, seconds), true)
|
event.target.seekTo(Math.max(0, seconds), true)
|
||||||
event.target.playVideo()
|
event.target.playVideo()
|
||||||
if (owner === 'composer') customMusicLoadFailed.value = false
|
if (owner === 'composer') customMusicLoadFailed.value = false
|
||||||
@@ -1627,7 +1634,10 @@ watch(originalVolume, (value) => {
|
|||||||
|
|
||||||
watch(musicVolume, (value) => {
|
watch(musicVolume, (value) => {
|
||||||
if (composerMusic.value) composerMusic.value.volume = value / 100
|
if (composerMusic.value) composerMusic.value.volume = value / 100
|
||||||
if (flipTokYoutubeOwner === 'composer') flipTokYoutubePlayer?.setVolume(value)
|
if (flipTokYoutubeOwner === 'composer') {
|
||||||
|
flipTokYoutubeVolume = value
|
||||||
|
flipTokYoutubePlayer?.setVolume(value * getPhoneOutputVolume())
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
@@ -1655,6 +1665,9 @@ watch(
|
|||||||
)
|
)
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
|
removePhoneOutputVolumeListener = subscribePhoneOutputVolume((volume) => {
|
||||||
|
flipTokYoutubePlayer?.setVolume(flipTokYoutubeVolume * volume)
|
||||||
|
})
|
||||||
const profileSelection = messageMedia.consumeMany<ProfileMediaContext>(
|
const profileSelection = messageMedia.consumeMany<ProfileMediaContext>(
|
||||||
'fliptok:profile-avatar',
|
'fliptok:profile-avatar',
|
||||||
)
|
)
|
||||||
@@ -1731,6 +1744,7 @@ onMounted(async () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
|
removePhoneOutputVolumeListener?.()
|
||||||
observer?.disconnect()
|
observer?.disconnect()
|
||||||
if (videoClickTimer !== null) window.clearTimeout(videoClickTimer)
|
if (videoClickTimer !== null) window.clearTimeout(videoClickTimer)
|
||||||
if (likePulseTimer !== null) window.clearTimeout(likePulseTimer)
|
if (likePulseTimer !== null) window.clearTimeout(likePulseTimer)
|
||||||
@@ -3381,7 +3395,7 @@ onBeforeUnmount(() => {
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: #000;
|
background: #000;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Text', sans-serif;
|
font-family: var(--sky-font-family);
|
||||||
}
|
}
|
||||||
.state,
|
.state,
|
||||||
.light-empty,
|
.light-empty,
|
||||||
|
|||||||
@@ -1526,7 +1526,7 @@ onMounted(async () => {
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: #12171b;
|
background: #12171b;
|
||||||
color: #f7f7f2;
|
color: #f7f7f2;
|
||||||
font-family: Inter, system-ui, sans-serif;
|
font-family: var(--sky-font-family);
|
||||||
}
|
}
|
||||||
.pages--light {
|
.pages--light {
|
||||||
--yellow: #8a6500;
|
--yellow: #8a6500;
|
||||||
|
|||||||
@@ -279,7 +279,7 @@ onBeforeUnmount(() => {
|
|||||||
background:
|
background:
|
||||||
radial-gradient(circle at 88% 8%, rgb(255 255 255 / 72%), transparent 28%),
|
radial-gradient(circle at 88% 8%, rgb(255 255 255 / 72%), transparent 28%),
|
||||||
linear-gradient(155deg, #f4efff 0%, #e7ddff 55%, #d9cdf7 100%);
|
linear-gradient(155deg, #f4efff 0%, #e7ddff 55%, #d9cdf7 100%);
|
||||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
font-family: var(--sky-font-family);
|
||||||
user-select: none;
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -420,7 +420,7 @@ onBeforeUnmount(() => {
|
|||||||
background:
|
background:
|
||||||
radial-gradient(circle at 88% 7%, rgb(108 231 218 / 32%), transparent 30%),
|
radial-gradient(circle at 88% 7%, rgb(108 231 218 / 32%), transparent 30%),
|
||||||
linear-gradient(155deg, #effcf7 0%, #cfeee5 52%, #abdcd7 100%);
|
linear-gradient(155deg, #effcf7 0%, #cfeee5 52%, #abdcd7 100%);
|
||||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
font-family: var(--sky-font-family);
|
||||||
touch-action: manipulation;
|
touch-action: manipulation;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -386,7 +386,7 @@ onBeforeUnmount(() => {
|
|||||||
padding: 52px 14px 25px;
|
padding: 52px 14px 25px;
|
||||||
color: #f5fbff;
|
color: #f5fbff;
|
||||||
background: radial-gradient(circle at 50% 0, #253163, #090c22 58%, #050715);
|
background: radial-gradient(circle at 50% 0, #253163, #090c22 58%, #050715);
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
font-family: var(--sky-font-family);
|
||||||
user-select: none;
|
user-select: none;
|
||||||
touch-action: manipulation;
|
touch-action: manipulation;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -335,7 +335,7 @@ onBeforeUnmount(() => window.removeEventListener('keydown', handleKeydown))
|
|||||||
background:
|
background:
|
||||||
radial-gradient(circle at 86% 8%, rgb(255 192 94 / 28%), transparent 29%),
|
radial-gradient(circle at 86% 8%, rgb(255 192 94 / 28%), transparent 29%),
|
||||||
linear-gradient(155deg, #fff3dc 0%, #f3d8b7 55%, #e8b98d 100%);
|
linear-gradient(155deg, #fff3dc 0%, #f3d8b7 55%, #e8b98d 100%);
|
||||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
font-family: var(--sky-font-family);
|
||||||
touch-action: none;
|
touch-action: none;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -167,7 +167,7 @@ onBeforeUnmount(() => {
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.flappy-app { --sky-a:#50d8f2;--sky-b:#765ce8;--tower:#574be8;--tower-light:#9b94ff;--tower-dark:#3429a6;--tower-glow:#79e7ff; position:absolute;inset:0;overflow:hidden;padding:52px 16px 27px;color:#fff;background:linear-gradient(160deg,#19375e,#433b80);font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;user-select:none;touch-action:manipulation; }
|
.flappy-app { --sky-a:#50d8f2;--sky-b:#765ce8;--tower:#574be8;--tower-light:#9b94ff;--tower-dark:#3429a6;--tower-glow:#79e7ff; position:absolute;inset:0;overflow:hidden;padding:52px 16px 27px;color:#fff;background:linear-gradient(160deg,#19375e,#433b80);font-family:var(--sky-font-family);user-select:none;touch-action:manipulation; }
|
||||||
.flappy-app--playing { padding:0; }
|
.flappy-app--playing { padding:0; }
|
||||||
.flappy-app--neon { --sky-a:#151c58;--sky-b:#a329a2;--tower:#19dfe6;--tower-light:#82ffff;--tower-dark:#087f91;--tower-glow:#26fbff; }.flappy-app--storm { --sky-a:#6f8497;--sky-b:#2b3955;--tower:#df765f;--tower-light:#ffb18e;--tower-dark:#8d3e39;--tower-glow:#ff997d; }
|
.flappy-app--neon { --sky-a:#151c58;--sky-b:#a329a2;--tower:#19dfe6;--tower-light:#82ffff;--tower-dark:#087f91;--tower-glow:#26fbff; }.flappy-app--storm { --sky-a:#6f8497;--sky-b:#2b3955;--tower:#df765f;--tower-light:#ffb18e;--tower-dark:#8d3e39;--tower-glow:#ff997d; }
|
||||||
.flappy-header{height:55px;display:flex;align-items:center;justify-content:space-between}.flappy-header span{display:block;color:#d9e9fa;font-size:14px;font-weight:850;letter-spacing:1.1px;text-transform:uppercase}.flappy-header h1{margin:1px 0 0;font-size:32px;line-height:1}.flappy-header button:not(.sky-button--glass),.flappy-toolbar button:not(.sky-button--glass){width:36px;height:36px;display:grid;place-items:center;padding:0;border:1px solid #ffffff35;border-radius:12px;color:#fff;background:#ffffff18}.flappy-header .sky-button--glass,.flappy-toolbar .sky-button--glass{color:#fff}
|
.flappy-header{height:55px;display:flex;align-items:center;justify-content:space-between}.flappy-header span{display:block;color:#d9e9fa;font-size:14px;font-weight:850;letter-spacing:1.1px;text-transform:uppercase}.flappy-header h1{margin:1px 0 0;font-size:32px;line-height:1}.flappy-header button:not(.sky-button--glass),.flappy-toolbar button:not(.sky-button--glass){width:36px;height:36px;display:grid;place-items:center;padding:0;border:1px solid #ffffff35;border-radius:12px;color:#fff;background:#ffffff18}.flappy-header .sky-button--glass,.flappy-toolbar .sky-button--glass{color:#fff}
|
||||||
|
|||||||
@@ -306,7 +306,7 @@ onBeforeUnmount(() => {
|
|||||||
background:
|
background:
|
||||||
radial-gradient(circle at 85% 8%, rgb(100 211 91 / 22%), transparent 30%),
|
radial-gradient(circle at 85% 8%, rgb(100 211 91 / 22%), transparent 30%),
|
||||||
linear-gradient(165deg, #142b25 0%, #0c1715 62%, #08100f 100%);
|
linear-gradient(165deg, #142b25 0%, #0c1715 62%, #08100f 100%);
|
||||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
font-family: var(--sky-font-family);
|
||||||
user-select: none;
|
user-select: none;
|
||||||
touch-action: none;
|
touch-action: none;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -321,7 +321,7 @@ onBeforeUnmount(() => {
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.tower-app { position: absolute; inset: 0; overflow: hidden; padding: 52px 16px 27px; color: #eef5ff; background: radial-gradient(circle at 75% 8%, #7146c866, transparent 35%), linear-gradient(170deg, #161634, #242054 52%, #10132c); font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; user-select: none; touch-action: manipulation; }
|
.tower-app { position: absolute; inset: 0; overflow: hidden; padding: 52px 16px 27px; color: #eef5ff; background: radial-gradient(circle at 75% 8%, #7146c866, transparent 35%), linear-gradient(170deg, #161634, #242054 52%, #10132c); font-family: var(--sky-font-family); user-select: none; touch-action: manipulation; }
|
||||||
.tower-app--playing { padding: 0; }
|
.tower-app--playing { padding: 0; }
|
||||||
.tower-header { height: 50px; display: flex; align-items: center; justify-content: space-between; }
|
.tower-header { height: 50px; display: flex; align-items: center; justify-content: space-between; }
|
||||||
.tower-header span { display: block; color: #c1b8f1; font-size: 10px; font-weight: 850; letter-spacing: 1.1px; text-transform: uppercase; }
|
.tower-header span { display: block; color: #c1b8f1; font-size: 10px; font-weight: 850; letter-spacing: 1.1px; text-transform: uppercase; }
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { readFileSync } from 'node:fs'
|
||||||
|
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
const gallerySource = readFileSync(
|
||||||
|
new URL('./PhoneDynamicIslandGallery.vue', import.meta.url),
|
||||||
|
'utf8',
|
||||||
|
)
|
||||||
|
const islandSource = readFileSync(
|
||||||
|
new URL('../../components/PhoneDynamicIsland.vue', import.meta.url),
|
||||||
|
'utf8',
|
||||||
|
)
|
||||||
|
const appSource = readFileSync(
|
||||||
|
new URL('../../App.vue', import.meta.url),
|
||||||
|
'utf8',
|
||||||
|
)
|
||||||
|
const routerSource = readFileSync(
|
||||||
|
new URL('../../router/index.ts', import.meta.url),
|
||||||
|
'utf8',
|
||||||
|
)
|
||||||
|
|
||||||
|
describe('Dynamic Island development gallery contract', () => {
|
||||||
|
it('shows every runtime activity and both supported presentation sizes', () => {
|
||||||
|
for (const activity of [
|
||||||
|
'incoming-call',
|
||||||
|
'call',
|
||||||
|
'music',
|
||||||
|
'recording',
|
||||||
|
'timer',
|
||||||
|
'stopwatch',
|
||||||
|
]) {
|
||||||
|
expect(gallerySource).toContain(`activity: '${activity}'`)
|
||||||
|
}
|
||||||
|
expect(gallerySource).toContain("label: 'Compact'")
|
||||||
|
expect(gallerySource).toContain("label: 'Expanded'")
|
||||||
|
expect(
|
||||||
|
gallerySource.match(
|
||||||
|
/activity: '(?:incoming-call|call|music|recording|timer|stopwatch)'/g,
|
||||||
|
),
|
||||||
|
).toHaveLength(11)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses the production component with inert preview data', () => {
|
||||||
|
expect(gallerySource).toContain('<PhoneDynamicIsland')
|
||||||
|
expect(gallerySource).toContain(':preview-activity="variant.activity"')
|
||||||
|
expect(islandSource).toContain('previewActivity?: DynamicIslandActivity')
|
||||||
|
expect(islandSource).toContain(
|
||||||
|
':data-preview="props.preview ? \'true\' : undefined"',
|
||||||
|
)
|
||||||
|
expect(islandSource).toContain(".phone-dynamic-island[data-preview='true']")
|
||||||
|
expect(gallerySource).toMatch(
|
||||||
|
/\.dynamic-island-gallery__stage\s*\{[^}]*width:\s*100%;/s,
|
||||||
|
)
|
||||||
|
expect(gallerySource).toMatch(
|
||||||
|
/dynamic-island-gallery__preview\.phone-dynamic-island[\s\S]*?max-width:\s*calc\(100% - 12px\);/,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps the gallery development-only and hides the live island above it', () => {
|
||||||
|
expect(routerSource).toContain("name: 'development-dynamic-islands'")
|
||||||
|
expect(routerSource).toContain("path: '/development/dynamic-islands'")
|
||||||
|
expect(routerSource).toMatch(
|
||||||
|
/const developmentRoutes[^=]*=\s*import\.meta\.env\.DEV/,
|
||||||
|
)
|
||||||
|
expect(appSource).toContain("route.name === 'development-dynamic-islands'")
|
||||||
|
expect(appSource).toContain(
|
||||||
|
'v-if="!setupRequired && !isDynamicIslandGalleryRoute"',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,303 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import PhoneDynamicIsland from '@/components/PhoneDynamicIsland.vue'
|
||||||
|
import type { DynamicIslandActivity } from '@/types/dynamicIsland'
|
||||||
|
|
||||||
|
type GalleryVariant = {
|
||||||
|
activity: DynamicIslandActivity
|
||||||
|
expanded: boolean
|
||||||
|
eyebrow: string
|
||||||
|
label: string
|
||||||
|
progress?: number
|
||||||
|
subtitle: string
|
||||||
|
title: string
|
||||||
|
value: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type GalleryGroup = {
|
||||||
|
label: string
|
||||||
|
variants: GalleryVariant[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const galleryGroups: GalleryGroup[] = [
|
||||||
|
{
|
||||||
|
label: 'Incoming call',
|
||||||
|
variants: [
|
||||||
|
{
|
||||||
|
activity: 'incoming-call',
|
||||||
|
expanded: true,
|
||||||
|
eyebrow: 'Incoming call',
|
||||||
|
label: 'Expanded',
|
||||||
|
subtitle: 'mobile',
|
||||||
|
title: 'Tania Castillo',
|
||||||
|
value: 'Incoming',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Active call',
|
||||||
|
variants: [
|
||||||
|
{
|
||||||
|
activity: 'call',
|
||||||
|
expanded: false,
|
||||||
|
eyebrow: 'Connected',
|
||||||
|
label: 'Compact',
|
||||||
|
subtitle: '00:42',
|
||||||
|
title: 'Alex Rivera',
|
||||||
|
value: '00:42',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
activity: 'call',
|
||||||
|
expanded: true,
|
||||||
|
eyebrow: 'Connected',
|
||||||
|
label: 'Expanded',
|
||||||
|
subtitle: '00:42',
|
||||||
|
title: 'Alex Rivera',
|
||||||
|
value: '00:42',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Music',
|
||||||
|
variants: [
|
||||||
|
{
|
||||||
|
activity: 'music',
|
||||||
|
expanded: false,
|
||||||
|
eyebrow: 'Now Playing',
|
||||||
|
label: 'Compact',
|
||||||
|
progress: 0.64,
|
||||||
|
subtitle: 'Los Santos Radio',
|
||||||
|
title: 'Night Drive',
|
||||||
|
value: 'Night Drive',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
activity: 'music',
|
||||||
|
expanded: true,
|
||||||
|
eyebrow: 'Now Playing',
|
||||||
|
label: 'Expanded',
|
||||||
|
progress: 0.64,
|
||||||
|
subtitle: 'Los Santos Radio',
|
||||||
|
title: 'Night Drive',
|
||||||
|
value: 'Night Drive',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Voice recording',
|
||||||
|
variants: [
|
||||||
|
{
|
||||||
|
activity: 'recording',
|
||||||
|
expanded: false,
|
||||||
|
eyebrow: 'Recording',
|
||||||
|
label: 'Compact',
|
||||||
|
subtitle: 'Voice memo',
|
||||||
|
title: '00:37',
|
||||||
|
value: '00:37',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
activity: 'recording',
|
||||||
|
expanded: true,
|
||||||
|
eyebrow: 'Recording',
|
||||||
|
label: 'Expanded',
|
||||||
|
subtitle: 'Voice memo',
|
||||||
|
title: '00:37',
|
||||||
|
value: '00:37',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Timer',
|
||||||
|
variants: [
|
||||||
|
{
|
||||||
|
activity: 'timer',
|
||||||
|
expanded: false,
|
||||||
|
eyebrow: 'Timer',
|
||||||
|
label: 'Compact',
|
||||||
|
progress: 0.47,
|
||||||
|
subtitle: '',
|
||||||
|
title: '0:21',
|
||||||
|
value: '0:21',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
activity: 'timer',
|
||||||
|
expanded: true,
|
||||||
|
eyebrow: 'Timer',
|
||||||
|
label: 'Expanded',
|
||||||
|
progress: 0.47,
|
||||||
|
subtitle: '',
|
||||||
|
title: '0:21',
|
||||||
|
value: '0:21',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Stopwatch',
|
||||||
|
variants: [
|
||||||
|
{
|
||||||
|
activity: 'stopwatch',
|
||||||
|
expanded: false,
|
||||||
|
eyebrow: 'Stopwatch',
|
||||||
|
label: 'Compact',
|
||||||
|
subtitle: '',
|
||||||
|
title: '00:11,48',
|
||||||
|
value: '00:11,48',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
activity: 'stopwatch',
|
||||||
|
expanded: true,
|
||||||
|
eyebrow: 'Stopwatch',
|
||||||
|
label: 'Expanded',
|
||||||
|
subtitle: '',
|
||||||
|
title: '00:11,48',
|
||||||
|
value: '00:11,48',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<main class="dynamic-island-gallery">
|
||||||
|
<header class="dynamic-island-gallery__header">
|
||||||
|
<span>Development preview</span>
|
||||||
|
<h1>Dynamic Islands</h1>
|
||||||
|
<p>Every runtime activity in its supported compact and expanded state.</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section
|
||||||
|
v-for="group in galleryGroups"
|
||||||
|
:key="group.label"
|
||||||
|
class="dynamic-island-gallery__group"
|
||||||
|
>
|
||||||
|
<h2>{{ group.label }}</h2>
|
||||||
|
<article
|
||||||
|
v-for="variant in group.variants"
|
||||||
|
:key="`${variant.activity}-${variant.label}`"
|
||||||
|
class="dynamic-island-gallery__variant"
|
||||||
|
>
|
||||||
|
<span>{{ variant.label }}</span>
|
||||||
|
<div
|
||||||
|
class="dynamic-island-gallery__stage"
|
||||||
|
:class="{
|
||||||
|
'dynamic-island-gallery__stage--expanded': variant.expanded,
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<PhoneDynamicIsland
|
||||||
|
class="dynamic-island-gallery__preview"
|
||||||
|
preview
|
||||||
|
:preview-activity="variant.activity"
|
||||||
|
:preview-expanded="variant.expanded"
|
||||||
|
:preview-eyebrow="variant.eyebrow"
|
||||||
|
:preview-progress="variant.progress"
|
||||||
|
:preview-subtitle="variant.subtitle"
|
||||||
|
:preview-title="variant.title"
|
||||||
|
:preview-value="variant.value"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.dynamic-island-gallery {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 2;
|
||||||
|
inset: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 58px 6px 32px;
|
||||||
|
color: #f7f7fb;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 50% 0%, rgb(96 75 180 / 22%), transparent 30%),
|
||||||
|
linear-gradient(180deg, #111116 0%, #070709 100%);
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dynamic-island-gallery::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dynamic-island-gallery__header,
|
||||||
|
.dynamic-island-gallery__group {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 356px;
|
||||||
|
margin-inline: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dynamic-island-gallery__header {
|
||||||
|
padding: 4px 8px 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dynamic-island-gallery__header span,
|
||||||
|
.dynamic-island-gallery__variant > span {
|
||||||
|
color: #a7a7b3;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 650;
|
||||||
|
letter-spacing: 0.55px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dynamic-island-gallery__header h1 {
|
||||||
|
margin: 4px 0 5px;
|
||||||
|
font-size: 28px;
|
||||||
|
letter-spacing: -0.8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dynamic-island-gallery__header p {
|
||||||
|
margin: 0;
|
||||||
|
color: #b6b6c2;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 17px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dynamic-island-gallery__group {
|
||||||
|
padding: 14px 0 18px;
|
||||||
|
border-top: 1px solid rgb(255 255 255 / 9%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dynamic-island-gallery__group h2 {
|
||||||
|
margin: 0 8px 10px;
|
||||||
|
font-size: 15px;
|
||||||
|
letter-spacing: -0.2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dynamic-island-gallery__variant + .dynamic-island-gallery__variant {
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dynamic-island-gallery__variant > span {
|
||||||
|
display: block;
|
||||||
|
margin: 0 10px 5px;
|
||||||
|
font-size: 9px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dynamic-island-gallery__stage {
|
||||||
|
display: grid;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 72px;
|
||||||
|
overflow: hidden;
|
||||||
|
place-items: center;
|
||||||
|
border: 1px solid rgb(255 255 255 / 8%);
|
||||||
|
border-radius: 30px;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 70% 15%, rgb(93 89 255 / 24%), transparent 34%),
|
||||||
|
#1a1a22;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dynamic-island-gallery__stage--expanded {
|
||||||
|
min-height: 142px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.dynamic-island-gallery__preview.phone-dynamic-island) {
|
||||||
|
position: relative;
|
||||||
|
top: auto;
|
||||||
|
left: auto;
|
||||||
|
max-width: calc(100% - 12px);
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.dynamic-island-gallery {
|
||||||
|
scroll-behavior: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -229,9 +229,7 @@ describe('development Sky UI Kitchen Sink contract', () => {
|
|||||||
/const developmentRoutes[^=]*=\s*import\.meta\.env\.DEV[\s\S]*?import\('@\/views\/development\/SkyUiKitchenSinkView\.vue'\)/,
|
/const developmentRoutes[^=]*=\s*import\.meta\.env\.DEV[\s\S]*?import\('@\/views\/development\/SkyUiKitchenSinkView\.vue'\)/,
|
||||||
)
|
)
|
||||||
expect(routerSource).toContain("name: 'development-sky-ui'")
|
expect(routerSource).toContain("name: 'development-sky-ui'")
|
||||||
expect(appSource).toContain(
|
expect(appSource).toContain("route.name === 'development-sky-ui'")
|
||||||
"isDevelopment && route.name === 'development-sky-ui'",
|
|
||||||
)
|
|
||||||
expect(appSource).toContain(
|
expect(appSource).toContain(
|
||||||
'isDevelopmentRoute ? String(route.name) : route.path',
|
'isDevelopmentRoute ? String(route.name) : route.path',
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ const lifecycleEndpoints = new Set([
|
|||||||
'notification:focus',
|
'notification:focus',
|
||||||
'sim:picker-close',
|
'sim:picker-close',
|
||||||
'ui:input-focus',
|
'ui:input-focus',
|
||||||
|
'ui:live-activity',
|
||||||
'ui:opened',
|
'ui:opened',
|
||||||
'ui:ready',
|
'ui:ready',
|
||||||
])
|
])
|
||||||
|
|||||||
@@ -1157,6 +1157,7 @@ async function main() {
|
|||||||
'notification:focus',
|
'notification:focus',
|
||||||
'sim:picker-close',
|
'sim:picker-close',
|
||||||
'ui:input-focus',
|
'ui:input-focus',
|
||||||
|
'ui:live-activity',
|
||||||
'ui:opened',
|
'ui:opened',
|
||||||
'ui:ready',
|
'ui:ready',
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
Copyright (c) 2016 The Inter Project Authors (https://github.com/rsms/inter)
|
||||||
|
|
||||||
|
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||||
|
This license is copied below, and is also available with a FAQ at:
|
||||||
|
http://scripts.sil.org/OFL
|
||||||
|
|
||||||
|
-----------------------------------------------------------
|
||||||
|
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||||
|
-----------------------------------------------------------
|
||||||
|
|
||||||
|
PREAMBLE
|
||||||
|
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||||
|
development of collaborative font projects, to support the font creation
|
||||||
|
efforts of academic and linguistic communities, and to provide a free and
|
||||||
|
open framework in which fonts may be shared and improved in partnership
|
||||||
|
with others.
|
||||||
|
|
||||||
|
The OFL allows the licensed fonts to be used, studied, modified and
|
||||||
|
redistributed freely as long as they are not sold by themselves. The
|
||||||
|
fonts, including any derivative works, can be bundled, embedded,
|
||||||
|
redistributed and/or sold with any software provided that any reserved
|
||||||
|
names are not used by derivative works. The fonts and derivatives,
|
||||||
|
however, cannot be released under any other type of license. The
|
||||||
|
requirement for fonts to remain under this license does not apply
|
||||||
|
to any document created using the fonts or their derivatives.
|
||||||
|
|
||||||
|
DEFINITIONS
|
||||||
|
"Font Software" refers to the set of files released by the Copyright
|
||||||
|
Holder(s) under this license and clearly marked as such. This may
|
||||||
|
include source files, build scripts and documentation.
|
||||||
|
|
||||||
|
"Reserved Font Name" refers to any names specified as such after the
|
||||||
|
copyright statement(s).
|
||||||
|
|
||||||
|
"Original Version" refers to the collection of Font Software components as
|
||||||
|
distributed by the Copyright Holder(s).
|
||||||
|
|
||||||
|
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||||
|
or substituting -- in part or in whole -- any of the components of the
|
||||||
|
Original Version, by changing formats or by porting the Font Software to a
|
||||||
|
new environment.
|
||||||
|
|
||||||
|
"Author" refers to any designer, engineer, programmer, technical
|
||||||
|
writer or other person who contributed to the Font Software.
|
||||||
|
|
||||||
|
PERMISSION AND CONDITIONS
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining
|
||||||
|
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||||
|
redistribute, and sell modified and unmodified copies of the Font
|
||||||
|
Software, subject to the following conditions:
|
||||||
|
|
||||||
|
1) Neither the Font Software nor any of its individual components,
|
||||||
|
in Original or Modified Versions, may be sold by itself.
|
||||||
|
|
||||||
|
2) Original or Modified Versions of the Font Software may be bundled,
|
||||||
|
redistributed and/or sold with any software, provided that each copy
|
||||||
|
contains the above copyright notice and this license. These can be
|
||||||
|
included either as stand-alone text files, human-readable headers or
|
||||||
|
in the appropriate machine-readable metadata fields within text or
|
||||||
|
binary files as long as those fields can be easily viewed by the user.
|
||||||
|
|
||||||
|
3) No Modified Version of the Font Software may use the Reserved Font
|
||||||
|
Name(s) unless explicit written permission is granted by the corresponding
|
||||||
|
Copyright Holder. This restriction only applies to the primary font name as
|
||||||
|
presented to the users.
|
||||||
|
|
||||||
|
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||||
|
Software shall not be used to promote, endorse or advertise any
|
||||||
|
Modified Version, except to acknowledge the contribution(s) of the
|
||||||
|
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||||
|
permission.
|
||||||
|
|
||||||
|
5) The Font Software, modified or unmodified, in part or in whole,
|
||||||
|
must be distributed entirely under this license, and must not be
|
||||||
|
distributed under any other license. The requirement for fonts to
|
||||||
|
remain under this license does not apply to any document created
|
||||||
|
using the Font Software.
|
||||||
|
|
||||||
|
TERMINATION
|
||||||
|
This license becomes null and void if any of the above conditions are not met.
|
||||||
|
|
||||||
|
DISCLAIMER
|
||||||
|
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||||
|
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||||
|
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||||
|
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||||
|
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||||
|
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||||
|
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER
|
||||||
|
DEALINGS IN THE FONT SOFTWARE.
|
||||||
@@ -1426,7 +1426,9 @@ Locales["de"] = {
|
|||||||
errors = {
|
errors = {
|
||||||
cancelled = "Die Aufnahme ist abgesagt.", capture_failed = "Die Spielansicht kann nicht erfasst werden.",
|
cancelled = "Die Aufnahme ist abgesagt.", capture_failed = "Die Spielansicht kann nicht erfasst werden.",
|
||||||
invalid_media_type = "Der hochgeladene Medientyp ist ungültig.", invalid_upload = "Der Upload konnte nicht überprüft werden.",
|
invalid_media_type = "Der hochgeladene Medientyp ist ungültig.", invalid_upload = "Der Upload konnte nicht überprüft werden.",
|
||||||
invalid_upload_token = "Die Upload-Sitzung ist nicht mehr gültig.", missing_config = "Kamera-Uploads sind nicht konfiguriert.",
|
invalid_upload_token = "Die Upload-Sitzung ist nicht mehr gültig.", media_provider_failed = "Der Kamera-Upload-Dienst ist nicht verfügbar.",
|
||||||
|
media_provider_rate_limited = "Der Kamera-Upload-Dienst ist ausgelastet. Versuch es gleich erneut.", media_provider_unauthorized = "Der konfigurierte FiveManage API-Schlüssel wurde abgelehnt.",
|
||||||
|
missing_config = "Kamera-Uploads sind nicht konfiguriert.",
|
||||||
microphone_unavailable = "Erlaube den Mikrofonzugriff oder schalte das Mikrofon vor der Aufnahme stumm.",
|
microphone_unavailable = "Erlaube den Mikrofonzugriff oder schalte das Mikrofon vor der Aufnahme stumm.",
|
||||||
not_found = "Das Medienobjekt existiert nicht mehr.", owner_changed = "Das aktive Telefonkonto hat sich beim Upload geändert.",
|
not_found = "Das Medienobjekt existiert nicht mehr.", owner_changed = "Das aktive Telefonkonto hat sich beim Upload geändert.",
|
||||||
operation_in_progress = "Eine weitere Medienoperation ist bereits im Gange.",
|
operation_in_progress = "Eine weitere Medienoperation ist bereits im Gange.",
|
||||||
|
|||||||
@@ -1426,7 +1426,9 @@ Locales["en"] = {
|
|||||||
errors = {
|
errors = {
|
||||||
cancelled = "Capture cancelled.", capture_failed = "Unable to capture the game view.",
|
cancelled = "Capture cancelled.", capture_failed = "Unable to capture the game view.",
|
||||||
invalid_media_type = "The uploaded media type is invalid.", invalid_upload = "The upload could not be verified.",
|
invalid_media_type = "The uploaded media type is invalid.", invalid_upload = "The upload could not be verified.",
|
||||||
invalid_upload_token = "The upload session is no longer valid.", missing_config = "Camera uploads are not configured.",
|
invalid_upload_token = "The upload session is no longer valid.", media_provider_failed = "The camera upload service is unavailable.",
|
||||||
|
media_provider_rate_limited = "The camera upload service is busy. Try again shortly.", media_provider_unauthorized = "The configured FiveManage API key was rejected.",
|
||||||
|
missing_config = "Camera uploads are not configured.",
|
||||||
microphone_unavailable = "Allow microphone access or mute the microphone before recording.",
|
microphone_unavailable = "Allow microphone access or mute the microphone before recording.",
|
||||||
not_found = "The media item no longer exists.", owner_changed = "The active phone account changed during upload.",
|
not_found = "The media item no longer exists.", owner_changed = "The active phone account changed during upload.",
|
||||||
operation_in_progress = "Another media operation is already in progress.",
|
operation_in_progress = "Another media operation is already in progress.",
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ use_experimental_fxv2_oal 'yes'
|
|||||||
|
|
||||||
author 'Sky-Systems'
|
author 'Sky-Systems'
|
||||||
description 'Sky Phone'
|
description 'Sky Phone'
|
||||||
version '0.1.0'
|
version '0.2.0'
|
||||||
|
|
||||||
provide 'lb-phone'
|
provide 'lb-phone'
|
||||||
provide '17mov_Phone'
|
provide '17mov_Phone'
|
||||||
@@ -106,6 +106,7 @@ server_scripts {
|
|||||||
'source/server/notifications.lua',
|
'source/server/notifications.lua',
|
||||||
'source/shared/public_api.lua',
|
'source/shared/public_api.lua',
|
||||||
'source/server/public_api.lua',
|
'source/server/public_api.lua',
|
||||||
|
'source/server/media_provider_config.lua',
|
||||||
'source/server/media_import.lua',
|
'source/server/media_import.lua',
|
||||||
'source/server/media_import/fivemanage.lua',
|
'source/server/media_import/fivemanage.lua',
|
||||||
'source/server/media_import/manifest.lua',
|
'source/server/media_import/manifest.lua',
|
||||||
|
|||||||
@@ -448,6 +448,13 @@ RegisterNUICallback("media:requestUpload", function(data, cb)
|
|||||||
cb({ success = false, error = "invalid_request" })
|
cb({ success = false, error = "invalid_request" })
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
Bridge.Debug(
|
||||||
|
"debug",
|
||||||
|
"[sky_phone][media-debug] NUI requested an upload (correlation=%s, type=%s).",
|
||||||
|
tostring(data.correlationId),
|
||||||
|
tostring(data.mediaType),
|
||||||
|
{ notice = true }
|
||||||
|
)
|
||||||
TriggerServerEvent("sky_phone:media:request-upload", data)
|
TriggerServerEvent("sky_phone:media:request-upload", data)
|
||||||
cb({ success = true })
|
cb({ success = true })
|
||||||
end)
|
end)
|
||||||
@@ -457,6 +464,15 @@ RegisterNUICallback("media:completeUpload", function(data, cb)
|
|||||||
cb({ success = false, error = "invalid_request" })
|
cb({ success = false, error = "invalid_request" })
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
Bridge.Debug(
|
||||||
|
"debug",
|
||||||
|
"[sky_phone][media-debug] NUI completed the provider upload (correlation=%s, remote-id=%s, url=%s, original-url=%s).",
|
||||||
|
tostring(data.correlationId),
|
||||||
|
type(data.remoteId) == "string" and "present" or "missing",
|
||||||
|
type(data.url) == "string" and "present" or "missing",
|
||||||
|
type(data.originalUrl) == "string" and "present" or "missing",
|
||||||
|
{ notice = true }
|
||||||
|
)
|
||||||
TriggerServerEvent("sky_phone:media:complete-upload", data)
|
TriggerServerEvent("sky_phone:media:complete-upload", data)
|
||||||
cb({ success = true })
|
cb({ success = true })
|
||||||
end)
|
end)
|
||||||
@@ -475,6 +491,16 @@ RegisterNUICallback("media:failUpload", function(data, cb)
|
|||||||
cb({ success = false, error = "invalid_request" })
|
cb({ success = false, error = "invalid_request" })
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
local debug_message = tostring(data.debugMessage or "unknown"):gsub("[\r\n]", " "):sub(1, 240)
|
||||||
|
Bridge.Debug(
|
||||||
|
"error",
|
||||||
|
"[sky_phone][media-debug] NUI reported an upload failure (correlation=%s, error=%s, stage=%s, status=%s, detail=%s).",
|
||||||
|
tostring(data.correlationId),
|
||||||
|
tostring(data.error),
|
||||||
|
tostring(data.debugStage),
|
||||||
|
tostring(data.debugStatus),
|
||||||
|
debug_message
|
||||||
|
)
|
||||||
TriggerServerEvent("sky_phone:media:fail-upload", data)
|
TriggerServerEvent("sky_phone:media:fail-upload", data)
|
||||||
cb({ success = true })
|
cb({ success = true })
|
||||||
end)
|
end)
|
||||||
@@ -534,10 +560,26 @@ RegisterNUICallback("memos:failUpload", function(data, cb)
|
|||||||
end)
|
end)
|
||||||
|
|
||||||
RegisterNetEvent("sky_phone:media:upload-ready", function(data)
|
RegisterNetEvent("sky_phone:media:upload-ready", function(data)
|
||||||
|
Bridge.Debug(
|
||||||
|
"debug",
|
||||||
|
"[sky_phone][media-debug] Client received upload-ready (correlation=%s, type=%s, presigned-url=%s).",
|
||||||
|
tostring(type(data) == "table" and data.correlationId),
|
||||||
|
tostring(type(data) == "table" and data.mediaType),
|
||||||
|
type(data) == "table" and type(data.presignedUrl) == "string" and "present" or "missing",
|
||||||
|
{ notice = true }
|
||||||
|
)
|
||||||
SendNUIMessage({ type = "media:uploadReady", data = data })
|
SendNUIMessage({ type = "media:uploadReady", data = data })
|
||||||
end)
|
end)
|
||||||
|
|
||||||
RegisterNetEvent("sky_phone:media:upload-result", function(data)
|
RegisterNetEvent("sky_phone:media:upload-result", function(data)
|
||||||
|
Bridge.Debug(
|
||||||
|
"debug",
|
||||||
|
"[sky_phone][media-debug] Client received upload-result (correlation=%s, success=%s, error=%s).",
|
||||||
|
tostring(type(data) == "table" and data.correlationId),
|
||||||
|
tostring(type(data) == "table" and data.success),
|
||||||
|
tostring(type(data) == "table" and data.error),
|
||||||
|
{ notice = true }
|
||||||
|
)
|
||||||
SendNUIMessage({ type = "media:uploadResult", data = data })
|
SendNUIMessage({ type = "media:uploadResult", data = data })
|
||||||
end)
|
end)
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ local open_without_focus = false
|
|||||||
local device_payload = nil
|
local device_payload = nil
|
||||||
local equipped_phone_number = nil
|
local equipped_phone_number = nil
|
||||||
local nui_generation = 0
|
local nui_generation = 0
|
||||||
|
local live_activity_active = false
|
||||||
|
local open_home_requested = false
|
||||||
|
|
||||||
local function get_equipped_phone_number()
|
local function get_equipped_phone_number()
|
||||||
if not device_payload or not device_payload.device.sim then
|
if not device_payload or not device_payload.device.sim then
|
||||||
@@ -75,7 +77,9 @@ local function send_open_message()
|
|||||||
SendNUIMessage({
|
SendNUIMessage({
|
||||||
type = "app:open",
|
type = "app:open",
|
||||||
data = payload,
|
data = payload,
|
||||||
|
openHome = open_home_requested,
|
||||||
})
|
})
|
||||||
|
open_home_requested = false
|
||||||
end
|
end
|
||||||
|
|
||||||
local function open_phone()
|
local function open_phone()
|
||||||
@@ -169,6 +173,25 @@ RegisterCommand("sky_phone_toggle", function()
|
|||||||
Bridge.Callbacks.Trigger("sky_phone:device:open-request", {})
|
Bridge.Callbacks.Trigger("sky_phone:device:open-request", {})
|
||||||
end, false)
|
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 Config.Phone.Keybind then
|
||||||
if type(Config.Phone.Keybind) ~= "string" or 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.")
|
error("[sky_phone] Config.Phone.Keybind must be a non-empty keyboard key name or false.")
|
||||||
@@ -255,6 +278,15 @@ RegisterNUICallback("ui:input-focus", function(data, cb)
|
|||||||
cb({ success = true })
|
cb({ success = true })
|
||||||
end)
|
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)
|
RegisterNUICallback("close", function(data, cb)
|
||||||
if type(data) ~= "table" then
|
if type(data) ~= "table" then
|
||||||
cb({ success = false, error = "invalid_request" })
|
cb({ success = false, error = "invalid_request" })
|
||||||
@@ -302,6 +334,8 @@ RegisterNetEvent("sky_phone:device:invalidated", function()
|
|||||||
close_phone(false)
|
close_phone(false)
|
||||||
device_payload = nil
|
device_payload = nil
|
||||||
update_equipped_phone_number(nil)
|
update_equipped_phone_number(nil)
|
||||||
|
live_activity_active = false
|
||||||
|
open_home_requested = false
|
||||||
end)
|
end)
|
||||||
|
|
||||||
RegisterNetEvent("sky_phone:device:error", function(error_code)
|
RegisterNetEvent("sky_phone:device:error", function(error_code)
|
||||||
|
|||||||
@@ -679,18 +679,16 @@ function SkyPhoneCompanies.GetCallTargets(company_id)
|
|||||||
end
|
end
|
||||||
for source, readiness in pairs(call_availability) do
|
for source, readiness in pairs(call_availability) do
|
||||||
local member = online[source] and call_member(source) or nil
|
local member = online[source] and call_member(source) or nil
|
||||||
local device = member and current_device(source, true) or nil
|
local device = member and SkyPhone.LoadDevice(readiness.imei) or nil
|
||||||
if not member or not device or member.company_id ~= readiness.company_id
|
local device_slots = device and SkyPhone.FindDeviceSlots(source, readiness.imei) or {}
|
||||||
or readiness.company_id ~= company_id or readiness.imei ~= device.imei
|
local readiness_valid = member and device and device_slots[1]
|
||||||
or readiness.sim_id ~= device.sim_id
|
and member.company_id == readiness.company_id
|
||||||
then
|
and device.sim_id == readiness.sim_id
|
||||||
if not member or not device or member.company_id ~= readiness.company_id
|
and device.sim_type == "registered"
|
||||||
or readiness.imei ~= (device and device.imei)
|
and device.registered_at ~= nil
|
||||||
or readiness.sim_id ~= (device and device.sim_id)
|
if not readiness_valid then
|
||||||
then
|
call_availability[source] = nil
|
||||||
call_availability[source] = nil
|
elseif readiness.company_id == company_id then
|
||||||
end
|
|
||||||
else
|
|
||||||
targets[#targets + 1] = {
|
targets[#targets + 1] = {
|
||||||
source = source,
|
source = source,
|
||||||
simId = device.sim_id,
|
simId = device.sim_id,
|
||||||
|
|||||||
+216
-113
@@ -4,36 +4,19 @@ SkyPhoneMediaImport.Initialize()
|
|||||||
|
|
||||||
local pending_uploads = {}
|
local pending_uploads = {}
|
||||||
local pending_deletes = {}
|
local pending_deletes = {}
|
||||||
local allowed_remote_mimes = {
|
local allowed_fivemanage_hosts = {
|
||||||
audio = {
|
["api.fivemanage.com"] = true,
|
||||||
["audio/ogg"] = true,
|
["fmapi.net"] = true,
|
||||||
["audio/webm"] = true,
|
|
||||||
},
|
|
||||||
photo = {
|
|
||||||
["image/jpeg"] = true,
|
|
||||||
["image/png"] = true,
|
|
||||||
["image/webp"] = true,
|
|
||||||
},
|
|
||||||
video = {
|
|
||||||
["video/mp4"] = true,
|
|
||||||
["video/webm"] = true,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
local function media_config()
|
local function diagnostic_text(value, maximum_length)
|
||||||
return Config.Media.FiveManage
|
return tostring(value or "unknown"):gsub("[\r\n]", " "):sub(1, maximum_length)
|
||||||
end
|
end
|
||||||
|
|
||||||
local function media_api_key()
|
local function media_debug(message, ...)
|
||||||
local api_key = media_config().ApiKey
|
local arguments = { ... }
|
||||||
if type(api_key) ~= "string" then
|
arguments[#arguments + 1] = { notice = true }
|
||||||
return ""
|
Bridge.Debug("debug", "[sky_phone][media-debug] " .. message, table.unpack(arguments))
|
||||||
end
|
|
||||||
return api_key:match("^%s*(.-)%s*$")
|
|
||||||
end
|
|
||||||
|
|
||||||
local function api_configured()
|
|
||||||
return media_api_key() ~= ""
|
|
||||||
end
|
end
|
||||||
|
|
||||||
local function http_request(url, method, body, headers, timeout_ms)
|
local function http_request(url, method, body, headers, timeout_ms)
|
||||||
@@ -65,6 +48,9 @@ local function response_error_message(response)
|
|||||||
if type(response) ~= "table" then
|
if type(response) ~= "table" then
|
||||||
return "invalid response"
|
return "invalid response"
|
||||||
end
|
end
|
||||||
|
if type(response.status) == "number" and response.status >= 200 and response.status < 300 then
|
||||||
|
return "none"
|
||||||
|
end
|
||||||
if type(response.error) == "string" and response.error ~= "" then
|
if type(response.error) == "string" and response.error ~= "" then
|
||||||
return response.error:sub(1, 240)
|
return response.error:sub(1, 240)
|
||||||
end
|
end
|
||||||
@@ -75,6 +61,10 @@ local function response_error_message(response)
|
|||||||
return message:sub(1, 240)
|
return message:sub(1, 240)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
local response_body = tostring(response.body or ""):gsub("[\r\n]", " ")
|
||||||
|
if response_body ~= "" then
|
||||||
|
return response_body:sub(1, 240)
|
||||||
|
end
|
||||||
return "no provider error message"
|
return "no provider error message"
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -95,8 +85,22 @@ local function decode_response(response)
|
|||||||
return decoded.data or decoded
|
return decoded.data or decoded
|
||||||
end
|
end
|
||||||
|
|
||||||
|
local function fivemanage_file_base(value)
|
||||||
|
if type(value) ~= "string" then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
local host = value:match("^https://([^/%?#]+)")
|
||||||
|
host = host and host:lower() or nil
|
||||||
|
if not host or not allowed_fivemanage_hosts[host] then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
return ("https://%s/api/v3/file"):format(host), host
|
||||||
|
end
|
||||||
|
|
||||||
local function request_presigned_url()
|
local function request_presigned_url()
|
||||||
if not api_configured() then
|
local api_key = SkyPhoneMediaProviderConfig.FiveManageApiKey()
|
||||||
|
media_debug("Starting FiveManage presigned upload request (api-key=%s).", api_key ~= "" and "present" or "missing")
|
||||||
|
if api_key == "" then
|
||||||
Bridge.Debug(
|
Bridge.Debug(
|
||||||
"error",
|
"error",
|
||||||
"[sky_phone] FiveManage presigned upload request failed: Config.Media.FiveManage.ApiKey is empty or invalid.",
|
"[sky_phone] FiveManage presigned upload request failed: Config.Media.FiveManage.ApiKey is empty or invalid.",
|
||||||
@@ -109,9 +113,10 @@ local function request_presigned_url()
|
|||||||
tostring(config.BaseUrl):gsub("/+$", "") .. "/presigned-url",
|
tostring(config.BaseUrl):gsub("/+$", "") .. "/presigned-url",
|
||||||
"GET",
|
"GET",
|
||||||
"",
|
"",
|
||||||
{ ["Authorization"] = media_api_key() },
|
{ ["Authorization"] = api_key },
|
||||||
tonumber(config.RequestTimeoutMs) or 10000
|
tonumber(config.RequestTimeoutMs) or 10000
|
||||||
)
|
)
|
||||||
|
media_debug("FiveManage presigned upload request returned HTTP %s.", tostring(response.status))
|
||||||
if response.status == 401 or response.status == 403 then
|
if response.status == 401 or response.status == 403 then
|
||||||
Bridge.Debug(
|
Bridge.Debug(
|
||||||
"error",
|
"error",
|
||||||
@@ -150,40 +155,41 @@ local function request_presigned_url()
|
|||||||
)
|
)
|
||||||
return nil, "media_provider_failed"
|
return nil, "media_provider_failed"
|
||||||
end
|
end
|
||||||
|
local provider_base_url, provider_host = fivemanage_file_base(presigned_url)
|
||||||
|
if not provider_base_url then
|
||||||
|
Bridge.Debug(
|
||||||
|
"error",
|
||||||
|
"[sky_phone] FiveManage returned a presigned URL on an unexpected host.",
|
||||||
|
{ always = true }
|
||||||
|
)
|
||||||
|
return nil, "media_provider_failed"
|
||||||
|
end
|
||||||
|
media_debug("FiveManage returned a valid presigned upload URL (host=%s).", provider_host)
|
||||||
return presigned_url
|
return presigned_url
|
||||||
end
|
end
|
||||||
|
|
||||||
local function get_remote_file(remote_id)
|
local function encode_remote_path(value)
|
||||||
if not api_configured() then
|
local segments = {}
|
||||||
return nil, "missing_config"
|
for segment in tostring(value):gmatch("[^/]+") do
|
||||||
|
segments[#segments + 1] = SkyPhoneMediaImport.UrlEncode(segment)
|
||||||
end
|
end
|
||||||
local config = Config.Media.FiveManage
|
return table.concat(segments, "/")
|
||||||
local response = http_request(
|
|
||||||
("%s/%s"):format(
|
|
||||||
tostring(config.BaseUrl):gsub("/+$", ""),
|
|
||||||
SkyPhoneMediaImport.UrlEncode(remote_id)
|
|
||||||
),
|
|
||||||
"GET",
|
|
||||||
"",
|
|
||||||
{ ["Authorization"] = media_api_key() },
|
|
||||||
tonumber(config.RequestTimeoutMs) or 10000
|
|
||||||
)
|
|
||||||
return decode_response(response)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
local function delete_remote_file(remote_id)
|
local function delete_remote_file(remote_id)
|
||||||
if not api_configured() then
|
local api_key = SkyPhoneMediaProviderConfig.FiveManageApiKey()
|
||||||
|
if api_key == "" then
|
||||||
return false, "missing_config"
|
return false, "missing_config"
|
||||||
end
|
end
|
||||||
local config = Config.Media.FiveManage
|
local config = Config.Media.FiveManage
|
||||||
local response = http_request(
|
local response = http_request(
|
||||||
("%s/%s"):format(
|
("%s/%s"):format(
|
||||||
tostring(config.BaseUrl):gsub("/+$", ""),
|
tostring(config.BaseUrl):gsub("/+$", ""),
|
||||||
SkyPhoneMediaImport.UrlEncode(remote_id)
|
encode_remote_path(remote_id)
|
||||||
),
|
),
|
||||||
"DELETE",
|
"DELETE",
|
||||||
"",
|
"",
|
||||||
{ ["Authorization"] = media_api_key() },
|
{ ["Authorization"] = api_key },
|
||||||
tonumber(config.RequestTimeoutMs) or 10000
|
tonumber(config.RequestTimeoutMs) or 10000
|
||||||
)
|
)
|
||||||
if response.status < 200 or response.status >= 300 then
|
if response.status < 200 or response.status >= 300 then
|
||||||
@@ -289,6 +295,14 @@ function SkyPhoneMedia.ResolveOwnedMedia(source, media_id, media_type)
|
|||||||
end
|
end
|
||||||
|
|
||||||
local function upload_result(source, correlation_id, success, error_code, media)
|
local function upload_result(source, correlation_id, success, error_code, media)
|
||||||
|
media_debug(
|
||||||
|
"Sending upload result (source=%s, correlation=%s, success=%s, error=%s, media=%s).",
|
||||||
|
tostring(source),
|
||||||
|
diagnostic_text(correlation_id, 80),
|
||||||
|
tostring(success),
|
||||||
|
diagnostic_text(error_code, 80),
|
||||||
|
type(media) == "table" and "present" or "missing"
|
||||||
|
)
|
||||||
TriggerClientEvent("sky_phone:media:upload-result", source, {
|
TriggerClientEvent("sky_phone:media:upload-result", source, {
|
||||||
correlationId = correlation_id,
|
correlationId = correlation_id,
|
||||||
success = success,
|
success = success,
|
||||||
@@ -315,79 +329,42 @@ local function delete_many_result(source, correlation_id, success, error_code, d
|
|||||||
})
|
})
|
||||||
end
|
end
|
||||||
|
|
||||||
local function parse_metadata(value)
|
|
||||||
if type(value) == "table" then
|
|
||||||
return value
|
|
||||||
end
|
|
||||||
if type(value) ~= "string" then
|
|
||||||
return nil
|
|
||||||
end
|
|
||||||
local success, decoded = pcall(json.decode, value)
|
|
||||||
return success and type(decoded) == "table" and decoded or nil
|
|
||||||
end
|
|
||||||
|
|
||||||
local function valid_remote_id(value)
|
local function valid_remote_id(value)
|
||||||
return type(value) == "string" and #value >= 4 and #value <= 128 and value:match("^[%w_%-]+$") ~= nil
|
return type(value) == "string" and #value >= 4 and #value <= 128 and value:match("^[%w_%-]+$") ~= nil
|
||||||
end
|
end
|
||||||
|
|
||||||
local function verify_remote_upload(state, remote_id, uploaded_url)
|
local function verify_remote_upload(state, remote_id, uploaded_url)
|
||||||
if not valid_remote_id(remote_id) or type(uploaded_url) ~= "string" or #uploaded_url > 2048
|
if not valid_remote_id(remote_id) or type(uploaded_url) ~= "string"
|
||||||
|
or #uploaded_url > Config.Media.UrlMaxLength
|
||||||
or not uploaded_url:match("^https://")
|
or not uploaded_url:match("^https://")
|
||||||
then
|
then
|
||||||
|
Bridge.Debug(
|
||||||
|
"error",
|
||||||
|
"[sky_phone][media-debug] Upload completion payload is invalid (remote-id=%s, url=%s).",
|
||||||
|
valid_remote_id(remote_id) and "valid" or "invalid",
|
||||||
|
type(uploaded_url) == "string" and uploaded_url:match("^https://") and "https" or "invalid"
|
||||||
|
)
|
||||||
return nil, "invalid_upload"
|
return nil, "invalid_upload"
|
||||||
end
|
end
|
||||||
local remote, remote_error = get_remote_file(remote_id)
|
|
||||||
if not remote then
|
local uploaded_host = uploaded_url:match("^https://([^/%?#]+)")
|
||||||
return nil, remote_error
|
if not uploaded_host or uploaded_host:lower() ~= "r2.fivemanage.com" then
|
||||||
end
|
Bridge.Debug(
|
||||||
if remote.id ~= remote_id then
|
"error",
|
||||||
|
"[sky_phone][media-debug] FiveManage upload returned an unexpected media host."
|
||||||
|
)
|
||||||
return nil, "invalid_upload"
|
return nil, "invalid_upload"
|
||||||
end
|
end
|
||||||
if remote.url ~= uploaded_url and remote.originalUrl ~= uploaded_url then
|
media_debug(
|
||||||
return nil, "invalid_upload"
|
"Accepting the direct FiveManage upload response (type=%s, size=%s).",
|
||||||
end
|
tostring(state.media_type),
|
||||||
local verified_url = remote.url or uploaded_url
|
tostring(state.size_bytes)
|
||||||
if type(verified_url) ~= "string" or #verified_url > Config.Media.UrlMaxLength
|
)
|
||||||
or not verified_url:match("^https://")
|
|
||||||
then
|
|
||||||
return nil, "invalid_upload"
|
|
||||||
end
|
|
||||||
local metadata = parse_metadata(remote.metadata)
|
|
||||||
if not metadata or metadata.captureToken ~= state.capture_token or metadata.source ~= "sky_phone" then
|
|
||||||
return nil, "invalid_upload_token"
|
|
||||||
end
|
|
||||||
if state.purpose and metadata.purpose ~= state.purpose then
|
|
||||||
return nil, "invalid_upload", true
|
|
||||||
end
|
|
||||||
local allowed_mimes = allowed_remote_mimes[state.media_type]
|
|
||||||
if not allowed_mimes then
|
|
||||||
return nil, "invalid_media_type", true
|
|
||||||
end
|
|
||||||
local remote_mime = tostring(remote.mimeType or ""):lower():match("^%s*([^;%s]+)") or ""
|
|
||||||
local remote_type = tostring(remote.type or ""):lower():match("^%s*([^;%s]+)") or ""
|
|
||||||
if remote_mime == "" and allowed_mimes[remote_type] then
|
|
||||||
remote_mime = remote_type
|
|
||||||
end
|
|
||||||
if remote_type == "" then
|
|
||||||
remote_type = remote_mime
|
|
||||||
end
|
|
||||||
if state.media_type == "photo" and remote_type ~= "" and not remote_type:find("image", 1, true) then
|
|
||||||
return nil, "invalid_media_type", true
|
|
||||||
end
|
|
||||||
if state.media_type == "video" and remote_type ~= "" and not remote_type:find("video", 1, true) then
|
|
||||||
return nil, "invalid_media_type", true
|
|
||||||
end
|
|
||||||
if state.media_type == "audio" and remote_type ~= "" and not remote_type:find("audio", 1, true) then
|
|
||||||
return nil, "invalid_media_type", true
|
|
||||||
end
|
|
||||||
if remote_mime ~= "" and not allowed_mimes[remote_mime] then
|
|
||||||
return nil, "invalid_media_type", true
|
|
||||||
end
|
|
||||||
return {
|
return {
|
||||||
mime_type = allowed_mimes[remote_mime] and remote_mime or state.mime_type,
|
mime_type = state.mime_type,
|
||||||
remote_id = remote_id,
|
remote_id = remote_id,
|
||||||
size = tonumber(remote.size),
|
size = state.size_bytes,
|
||||||
url = verified_url,
|
url = uploaded_url,
|
||||||
}, nil, true
|
}, nil, true
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -401,6 +378,12 @@ local function expire_upload(request_id)
|
|||||||
return
|
return
|
||||||
end
|
end
|
||||||
pending_uploads[request_id] = nil
|
pending_uploads[request_id] = nil
|
||||||
|
Bridge.Debug(
|
||||||
|
"error",
|
||||||
|
"[sky_phone][media-debug] Upload session expired before completion (source=%s, correlation=%s).",
|
||||||
|
tostring(state.source),
|
||||||
|
diagnostic_text(state.correlation_id, 80)
|
||||||
|
)
|
||||||
upload_result(state.source, state.correlation_id, false, "upload_timeout")
|
upload_result(state.source, state.correlation_id, false, "upload_timeout")
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -660,35 +643,81 @@ RegisterNetEvent("sky_phone:media:request-upload", function(data)
|
|||||||
data = type(data) == "table" and data or {}
|
data = type(data) == "table" and data or {}
|
||||||
local correlation_id = data.correlationId
|
local correlation_id = data.correlationId
|
||||||
local media_type = data.mediaType
|
local media_type = data.mediaType
|
||||||
|
media_debug(
|
||||||
|
"Server received upload request (source=%s, correlation=%s, type=%s).",
|
||||||
|
tostring(src),
|
||||||
|
diagnostic_text(correlation_id, 80),
|
||||||
|
diagnostic_text(media_type, 20)
|
||||||
|
)
|
||||||
if type(correlation_id) ~= "string" or #correlation_id > 80
|
if type(correlation_id) ~= "string" or #correlation_id > 80
|
||||||
or (media_type ~= "photo" and media_type ~= "video")
|
or (media_type ~= "photo" and media_type ~= "video")
|
||||||
then
|
then
|
||||||
|
Bridge.Debug(
|
||||||
|
"error",
|
||||||
|
"[sky_phone][media-debug] Upload request validation failed (source=%s, correlation-type=%s, correlation-length=%s, media-type=%s).",
|
||||||
|
tostring(src),
|
||||||
|
type(correlation_id),
|
||||||
|
type(correlation_id) == "string" and tostring(#correlation_id) or "invalid",
|
||||||
|
diagnostic_text(media_type, 20)
|
||||||
|
)
|
||||||
upload_result(src, correlation_id, false, "invalid_request")
|
upload_result(src, correlation_id, false, "invalid_request")
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
if not SkyPhone.AllowOperation(src, "media_write", 20, 60) then
|
if not SkyPhone.AllowOperation(src, "media_write", 20, 60) then
|
||||||
|
Bridge.Debug(
|
||||||
|
"warn",
|
||||||
|
"[sky_phone][media-debug] Upload request was rate limited (source=%s, correlation=%s).",
|
||||||
|
tostring(src),
|
||||||
|
diagnostic_text(correlation_id, 80)
|
||||||
|
)
|
||||||
upload_result(src, correlation_id, false, "rate_limited")
|
upload_result(src, correlation_id, false, "rate_limited")
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
local owner, error_response = session_owner(src)
|
local owner, error_response = session_owner(src)
|
||||||
if not owner then
|
if not owner then
|
||||||
|
Bridge.Debug(
|
||||||
|
"error",
|
||||||
|
"[sky_phone][media-debug] Upload request has no valid phone session (source=%s, correlation=%s, error=%s).",
|
||||||
|
tostring(src),
|
||||||
|
diagnostic_text(correlation_id, 80),
|
||||||
|
diagnostic_text(error_response and error_response.error, 80)
|
||||||
|
)
|
||||||
upload_result(src, correlation_id, false, error_response.error)
|
upload_result(src, correlation_id, false, error_response.error)
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
media_debug(
|
||||||
|
"Upload request session resolved (source=%s, correlation=%s, owner=%s).",
|
||||||
|
tostring(src),
|
||||||
|
diagnostic_text(correlation_id, 80),
|
||||||
|
owner.account_id and "account" or "device"
|
||||||
|
)
|
||||||
local presigned_url, presigned_error = request_presigned_url()
|
local presigned_url, presigned_error = request_presigned_url()
|
||||||
if not presigned_url then
|
if not presigned_url then
|
||||||
|
Bridge.Debug(
|
||||||
|
"error",
|
||||||
|
"[sky_phone][media-debug] Presigned upload request failed (source=%s, correlation=%s, error=%s).",
|
||||||
|
tostring(src),
|
||||||
|
diagnostic_text(correlation_id, 80),
|
||||||
|
diagnostic_text(presigned_error, 80)
|
||||||
|
)
|
||||||
upload_result(src, correlation_id, false, presigned_error)
|
upload_result(src, correlation_id, false, presigned_error)
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
local ids = Bridge.Database.Query("SELECT UUID() AS `request_id`, UUID() AS `capture_token`", {})
|
local ids = Bridge.Database.Query("SELECT UUID() AS `request_id`", {})
|
||||||
local request_id = ids[1] and ids[1].request_id
|
local request_id = ids[1] and ids[1].request_id
|
||||||
local capture_token = ids[1] and ids[1].capture_token
|
if type(request_id) ~= "string" then
|
||||||
if type(request_id) ~= "string" or type(capture_token) ~= "string" then
|
Bridge.Debug(
|
||||||
|
"error",
|
||||||
|
"[sky_phone][media-debug] Database did not generate an upload request ID (source=%s, correlation=%s, rows=%s, request-id=%s).",
|
||||||
|
tostring(src),
|
||||||
|
diagnostic_text(correlation_id, 80),
|
||||||
|
tostring(type(ids) == "table" and #ids or 0),
|
||||||
|
type(request_id)
|
||||||
|
)
|
||||||
upload_result(src, correlation_id, false, "request_failed")
|
upload_result(src, correlation_id, false, "request_failed")
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
pending_uploads[request_id] = {
|
pending_uploads[request_id] = {
|
||||||
capture_token = capture_token,
|
|
||||||
correlation_id = correlation_id,
|
correlation_id = correlation_id,
|
||||||
media_type = media_type,
|
media_type = media_type,
|
||||||
mime_type = media_type == "video" and "video/webm"
|
mime_type = media_type == "video" and "video/webm"
|
||||||
@@ -700,8 +729,13 @@ RegisterNetEvent("sky_phone:media:request-upload", function(data)
|
|||||||
SetTimeout(tonumber(Config.Media.UploadSessionTimeoutMs) or 60000, function()
|
SetTimeout(tonumber(Config.Media.UploadSessionTimeoutMs) or 60000, function()
|
||||||
expire_upload(request_id)
|
expire_upload(request_id)
|
||||||
end)
|
end)
|
||||||
|
media_debug(
|
||||||
|
"Sending upload-ready to client (source=%s, correlation=%s, type=%s).",
|
||||||
|
tostring(src),
|
||||||
|
diagnostic_text(correlation_id, 80),
|
||||||
|
tostring(media_type)
|
||||||
|
)
|
||||||
TriggerClientEvent("sky_phone:media:upload-ready", src, {
|
TriggerClientEvent("sky_phone:media:upload-ready", src, {
|
||||||
captureToken = capture_token,
|
|
||||||
correlationId = correlation_id,
|
correlationId = correlation_id,
|
||||||
mediaType = media_type,
|
mediaType = media_type,
|
||||||
photo = Config.Media.Photo,
|
photo = Config.Media.Photo,
|
||||||
@@ -718,18 +752,55 @@ RegisterNetEvent("sky_phone:media:complete-upload", function(data)
|
|||||||
local request_id = data.requestId
|
local request_id = data.requestId
|
||||||
local state = type(request_id) == "string" and pending_uploads[request_id] or nil
|
local state = type(request_id) == "string" and pending_uploads[request_id] or nil
|
||||||
if not state or state.source ~= src or state.completing then
|
if not state or state.source ~= src or state.completing then
|
||||||
|
Bridge.Debug(
|
||||||
|
"warn",
|
||||||
|
"[sky_phone][media-debug] Rejected upload completion (source=%s, request-id=%s, session=%s, owner-match=%s, completing=%s).",
|
||||||
|
tostring(src),
|
||||||
|
type(request_id) == "string" and "present" or "invalid",
|
||||||
|
state and "present" or "missing",
|
||||||
|
tostring(state and state.source == src),
|
||||||
|
tostring(state and state.completing)
|
||||||
|
)
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
media_debug(
|
||||||
|
"Server received upload completion (source=%s, correlation=%s, remote-id=%s, url=%s).",
|
||||||
|
tostring(src),
|
||||||
|
diagnostic_text(state.correlation_id, 80),
|
||||||
|
type(data.remoteId) == "string" and "present" or "missing",
|
||||||
|
type(data.url) == "string" and "present" or "missing"
|
||||||
|
)
|
||||||
state.completing = true
|
state.completing = true
|
||||||
local owner, error_response = session_owner(src)
|
local owner, error_response = session_owner(src)
|
||||||
if not owner or not owners_match(owner, state.owner) then
|
if not owner or not owners_match(owner, state.owner) then
|
||||||
pending_uploads[request_id] = nil
|
pending_uploads[request_id] = nil
|
||||||
|
Bridge.Debug(
|
||||||
|
"error",
|
||||||
|
"[sky_phone][media-debug] Upload owner changed before completion (source=%s, correlation=%s, session=%s, owner-match=%s, error=%s).",
|
||||||
|
tostring(src),
|
||||||
|
diagnostic_text(state.correlation_id, 80),
|
||||||
|
owner and "present" or "missing",
|
||||||
|
tostring(owner and owners_match(owner, state.owner)),
|
||||||
|
diagnostic_text(error_response and error_response.error, 80)
|
||||||
|
)
|
||||||
upload_result(src, state.correlation_id, false, error_response and error_response.error or "owner_changed")
|
upload_result(src, state.correlation_id, false, error_response and error_response.error or "owner_changed")
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
local verified, verify_error, trusted_remote = verify_remote_upload(state, data.remoteId, data.url)
|
local verified, verify_error, trusted_remote = verify_remote_upload(
|
||||||
|
state,
|
||||||
|
data.remoteId,
|
||||||
|
data.url
|
||||||
|
)
|
||||||
if not verified then
|
if not verified then
|
||||||
pending_uploads[request_id] = nil
|
pending_uploads[request_id] = nil
|
||||||
|
Bridge.Debug(
|
||||||
|
"error",
|
||||||
|
"[sky_phone][media-debug] Uploaded file verification failed (source=%s, correlation=%s, error=%s, trusted-remote=%s).",
|
||||||
|
tostring(src),
|
||||||
|
diagnostic_text(state.correlation_id, 80),
|
||||||
|
diagnostic_text(verify_error, 80),
|
||||||
|
tostring(trusted_remote)
|
||||||
|
)
|
||||||
if trusted_remote then
|
if trusted_remote then
|
||||||
local deleted, delete_error = delete_remote_file(data.remoteId)
|
local deleted, delete_error = delete_remote_file(data.remoteId)
|
||||||
if not deleted then
|
if not deleted then
|
||||||
@@ -760,9 +831,22 @@ RegisterNetEvent("sky_phone:media:complete-upload", function(data)
|
|||||||
local media_id = type(result) == "number" and result or (type(result) == "table" and tonumber(result.insertId))
|
local media_id = type(result) == "number" and result or (type(result) == "table" and tonumber(result.insertId))
|
||||||
if not media_id then
|
if not media_id then
|
||||||
delete_remote_file(verified.remote_id)
|
delete_remote_file(verified.remote_id)
|
||||||
|
Bridge.Debug(
|
||||||
|
"error",
|
||||||
|
"[sky_phone][media-debug] Database insert did not return a media ID (source=%s, correlation=%s, result-type=%s).",
|
||||||
|
tostring(src),
|
||||||
|
diagnostic_text(state.correlation_id, 80),
|
||||||
|
type(result)
|
||||||
|
)
|
||||||
upload_result(src, state.correlation_id, false, "request_failed")
|
upload_result(src, state.correlation_id, false, "request_failed")
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
media_debug(
|
||||||
|
"Media upload completed successfully (source=%s, correlation=%s, media-id=%s).",
|
||||||
|
tostring(src),
|
||||||
|
diagnostic_text(state.correlation_id, 80),
|
||||||
|
tostring(media_id)
|
||||||
|
)
|
||||||
upload_result(src, state.correlation_id, true, nil, {
|
upload_result(src, state.correlation_id, true, nil, {
|
||||||
id = media_id,
|
id = media_id,
|
||||||
url = verified.url,
|
url = verified.url,
|
||||||
@@ -787,6 +871,15 @@ RegisterNetEvent("sky_phone:media:fail-upload", function(data)
|
|||||||
local request_id = type(data) == "table" and data.requestId or nil
|
local request_id = type(data) == "table" and data.requestId or nil
|
||||||
local state = type(request_id) == "string" and pending_uploads[request_id] or nil
|
local state = type(request_id) == "string" and pending_uploads[request_id] or nil
|
||||||
if not state or state.source ~= src or state.completing then
|
if not state or state.source ~= src or state.completing then
|
||||||
|
Bridge.Debug(
|
||||||
|
"warn",
|
||||||
|
"[sky_phone][media-debug] Rejected client upload failure report (source=%s, request-id=%s, session=%s, owner-match=%s, completing=%s).",
|
||||||
|
tostring(src),
|
||||||
|
type(request_id) == "string" and "present" or "invalid",
|
||||||
|
state and "present" or "missing",
|
||||||
|
tostring(state and state.source == src),
|
||||||
|
tostring(state and state.completing)
|
||||||
|
)
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
local allowed_errors = {
|
local allowed_errors = {
|
||||||
@@ -797,6 +890,16 @@ RegisterNetEvent("sky_phone:media:fail-upload", function(data)
|
|||||||
}
|
}
|
||||||
pending_uploads[request_id] = nil
|
pending_uploads[request_id] = nil
|
||||||
local error_code = allowed_errors[data.error] and data.error or "upload_failed"
|
local error_code = allowed_errors[data.error] and data.error or "upload_failed"
|
||||||
|
Bridge.Debug(
|
||||||
|
"error",
|
||||||
|
"[sky_phone][media-debug] Client-reported upload failure (source=%s, correlation=%s, error=%s, stage=%s, status=%s, detail=%s).",
|
||||||
|
tostring(src),
|
||||||
|
diagnostic_text(state.correlation_id, 80),
|
||||||
|
diagnostic_text(error_code, 80),
|
||||||
|
diagnostic_text(data.debugStage, 40),
|
||||||
|
diagnostic_text(data.debugStatus, 20),
|
||||||
|
diagnostic_text(data.debugMessage, 240)
|
||||||
|
)
|
||||||
upload_result(src, state.correlation_id, false, error_code)
|
upload_result(src, state.correlation_id, false, error_code)
|
||||||
end)
|
end)
|
||||||
|
|
||||||
@@ -981,7 +1084,7 @@ AddEventHandler("playerDropped", function()
|
|||||||
end
|
end
|
||||||
end)
|
end)
|
||||||
|
|
||||||
if not api_configured() then
|
if SkyPhoneMediaProviderConfig.FiveManageApiKey() == "" then
|
||||||
Bridge.Debug(
|
Bridge.Debug(
|
||||||
"warn",
|
"warn",
|
||||||
"[sky_phone] FiveManage media integration is disabled because Config.Media.FiveManage.ApiKey is empty in config/media.lua. Camera photo and video uploads, Voice Memo uploads, remote Gallery deletion, and FiveManage imports are unavailable. Add a FiveManage V3 token with Media access and restart sky_phone.",
|
"[sky_phone] FiveManage media integration is disabled because Config.Media.FiveManage.ApiKey is empty in config/media.lua. Camera photo and video uploads, Voice Memo uploads, remote Gallery deletion, and FiveManage imports are unavailable. Add a FiveManage V3 token with Media access and restart sky_phone.",
|
||||||
|
|||||||
@@ -1,9 +1,5 @@
|
|||||||
local function api_key(website)
|
local function api_key(website)
|
||||||
local configured_key = website.ApiKey or Config.Media.FiveManage.ApiKey
|
return SkyPhoneMediaProviderConfig.FiveManageApiKey(website.ApiKey)
|
||||||
if type(configured_key) ~= "string" then
|
|
||||||
return ""
|
|
||||||
end
|
|
||||||
return configured_key:match("^%s*(.-)%s*$")
|
|
||||||
end
|
end
|
||||||
|
|
||||||
local function provider_error(response, not_found_error)
|
local function provider_error(response, not_found_error)
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
SkyPhoneMediaProviderConfig = {}
|
||||||
|
|
||||||
|
local function trim_key(value)
|
||||||
|
if type(value) ~= "string" then
|
||||||
|
return ""
|
||||||
|
end
|
||||||
|
return value:match("^%s*(.-)%s*$")
|
||||||
|
end
|
||||||
|
|
||||||
|
function SkyPhoneMediaProviderConfig.FiveManageApiKey(override_key)
|
||||||
|
local api_key = trim_key(override_key)
|
||||||
|
if api_key ~= "" then
|
||||||
|
return api_key
|
||||||
|
end
|
||||||
|
|
||||||
|
return trim_key(Config.Media.FiveManage.ApiKey)
|
||||||
|
end
|
||||||
@@ -51,6 +51,23 @@ local function session_owner(source)
|
|||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
local function device_owner(source, imei)
|
||||||
|
if not SkyPhoneImei.IsValid(imei) then
|
||||||
|
return nil, { success = false, error = "invalid_request" }
|
||||||
|
end
|
||||||
|
if not SkyPhone.FindDeviceSlots(source, imei)[1] then
|
||||||
|
return nil, { success = false, error = "owner_changed" }
|
||||||
|
end
|
||||||
|
local device = SkyPhone.LoadDevice(imei)
|
||||||
|
if not device then
|
||||||
|
return nil, { success = false, error = "device_not_found" }
|
||||||
|
end
|
||||||
|
return {
|
||||||
|
account_id = device.account_id and tonumber(device.account_id) or nil,
|
||||||
|
imei = imei,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
local function owner_condition(owner, alias)
|
local function owner_condition(owner, alias)
|
||||||
local prefix = alias and ("`%s`."):format(alias) or ""
|
local prefix = alias and ("`%s`."):format(alias) or ""
|
||||||
if owner.account_id then
|
if owner.account_id then
|
||||||
@@ -153,6 +170,7 @@ local function validate_upload(data)
|
|||||||
local title_length = title and utf8.len(title) or nil
|
local title_length = title and utf8.len(title) or nil
|
||||||
local note_length = note and utf8.len(note) or nil
|
local note_length = note and utf8.len(note) or nil
|
||||||
local duration_ms = tonumber(data.durationMs)
|
local duration_ms = tonumber(data.durationMs)
|
||||||
|
local size_bytes = tonumber(data.sizeBytes)
|
||||||
local normalized_mime = allowed_audio_mimes[data.mimeType]
|
local normalized_mime = allowed_audio_mimes[data.mimeType]
|
||||||
local waveform = normalize_waveform(data.waveform)
|
local waveform = normalize_waveform(data.waveform)
|
||||||
if not title_length or title_length < 1 or title_length > Config.Memos.TitleMaxLength
|
if not title_length or title_length < 1 or title_length > Config.Memos.TitleMaxLength
|
||||||
@@ -160,6 +178,8 @@ local function validate_upload(data)
|
|||||||
or not duration_ms or duration_ms ~= duration_ms
|
or not duration_ms or duration_ms ~= duration_ms
|
||||||
or duration_ms == math.huge or duration_ms == -math.huge
|
or duration_ms == math.huge or duration_ms == -math.huge
|
||||||
or duration_ms < 300 or duration_ms > Config.Memos.MaximumDurationMs
|
or duration_ms < 300 or duration_ms > Config.Memos.MaximumDurationMs
|
||||||
|
or not size_bytes or size_bytes ~= math.floor(size_bytes)
|
||||||
|
or size_bytes < 1 or size_bytes > Config.Memos.MaximumBytes
|
||||||
or not normalized_mime or not waveform or type(data.pinned) ~= "boolean"
|
or not normalized_mime or not waveform or type(data.pinned) ~= "boolean"
|
||||||
then
|
then
|
||||||
return nil
|
return nil
|
||||||
@@ -171,6 +191,7 @@ local function validate_upload(data)
|
|||||||
mime_type = normalized_mime,
|
mime_type = normalized_mime,
|
||||||
note = note,
|
note = note,
|
||||||
pinned = data.pinned,
|
pinned = data.pinned,
|
||||||
|
size_bytes = size_bytes,
|
||||||
title = title,
|
title = title,
|
||||||
waveform = waveform,
|
waveform = waveform,
|
||||||
}
|
}
|
||||||
@@ -317,7 +338,7 @@ RegisterNetEvent("sky_phone:memos:request-upload", function(data)
|
|||||||
upload_result(src, type(data) == "table" and data.correlationId or nil, false, "invalid_memo")
|
upload_result(src, type(data) == "table" and data.correlationId or nil, false, "invalid_memo")
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
local owner, error_response = session_owner(src)
|
local owner, error_response = device_owner(src, data.deviceImei)
|
||||||
if not owner then
|
if not owner then
|
||||||
upload_result(src, memo.correlation_id, false, error_response.error)
|
upload_result(src, memo.correlation_id, false, error_response.error)
|
||||||
return
|
return
|
||||||
@@ -356,20 +377,18 @@ RegisterNetEvent("sky_phone:memos:request-upload", function(data)
|
|||||||
upload_result(src, memo.correlation_id, false, presigned_error)
|
upload_result(src, memo.correlation_id, false, presigned_error)
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
local ids = Bridge.Database.Query("SELECT UUID() AS `request_id`, UUID() AS `capture_token`", {})
|
local ids = Bridge.Database.Query("SELECT UUID() AS `request_id`", {})
|
||||||
local request_id = ids[1] and ids[1].request_id
|
local request_id = ids[1] and ids[1].request_id
|
||||||
local capture_token = ids[1] and ids[1].capture_token
|
if type(request_id) ~= "string" then
|
||||||
if type(request_id) ~= "string" or type(capture_token) ~= "string" then
|
error("[sky_phone] Database did not generate a voice memo upload request ID.")
|
||||||
error("[sky_phone] Database did not generate a voice memo upload token.")
|
|
||||||
end
|
end
|
||||||
pending_uploads[request_id] = {
|
pending_uploads[request_id] = {
|
||||||
capture_token = capture_token,
|
|
||||||
media_type = "audio",
|
media_type = "audio",
|
||||||
mime_type = memo.mime_type,
|
mime_type = memo.mime_type,
|
||||||
memo = memo,
|
memo = memo,
|
||||||
owner = owner,
|
owner = owner,
|
||||||
owner_key = pending_owner_key,
|
owner_key = pending_owner_key,
|
||||||
purpose = "memo",
|
size_bytes = memo.size_bytes,
|
||||||
source = src,
|
source = src,
|
||||||
}
|
}
|
||||||
SetTimeout(Config.Memos.UploadSessionTimeoutMs, function()
|
SetTimeout(Config.Memos.UploadSessionTimeoutMs, function()
|
||||||
@@ -378,7 +397,6 @@ RegisterNetEvent("sky_phone:memos:request-upload", function(data)
|
|||||||
TriggerClientEvent("sky_phone:memos:upload-ready", src, {
|
TriggerClientEvent("sky_phone:memos:upload-ready", src, {
|
||||||
requestId = request_id,
|
requestId = request_id,
|
||||||
correlationId = memo.correlation_id,
|
correlationId = memo.correlation_id,
|
||||||
captureToken = capture_token,
|
|
||||||
presignedUrl = presigned_url,
|
presignedUrl = presigned_url,
|
||||||
uploadTimeoutMs = Config.Media.FiveManage.UploadTimeoutMs,
|
uploadTimeoutMs = Config.Media.FiveManage.UploadTimeoutMs,
|
||||||
})
|
})
|
||||||
@@ -392,17 +410,25 @@ RegisterNetEvent("sky_phone:memos:complete-upload", function(data)
|
|||||||
return
|
return
|
||||||
end
|
end
|
||||||
state.completing = true
|
state.completing = true
|
||||||
local owner, error_response = session_owner(src)
|
local owner, error_response = device_owner(src, state.owner.imei)
|
||||||
if not owner or owner.imei ~= state.owner.imei or owner.account_id ~= state.owner.account_id then
|
if not owner or owner.imei ~= state.owner.imei or owner.account_id ~= state.owner.account_id then
|
||||||
pending_uploads[request_id] = nil
|
pending_uploads[request_id] = nil
|
||||||
local rejected, _, trusted_remote = SkyPhoneMedia.VerifyRemoteUpload(state, data.remoteId, data.url)
|
local rejected, _, trusted_remote = SkyPhoneMedia.VerifyRemoteUpload(
|
||||||
|
state,
|
||||||
|
data.remoteId,
|
||||||
|
data.url
|
||||||
|
)
|
||||||
if rejected or trusted_remote then
|
if rejected or trusted_remote then
|
||||||
discard_verified_upload(data.remoteId)
|
discard_verified_upload(data.remoteId)
|
||||||
end
|
end
|
||||||
upload_result(src, state.memo.correlation_id, false, error_response and error_response.error or "owner_changed")
|
upload_result(src, state.memo.correlation_id, false, error_response and error_response.error or "owner_changed")
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
local verified, verify_error, trusted_remote = SkyPhoneMedia.VerifyRemoteUpload(state, data.remoteId, data.url)
|
local verified, verify_error, trusted_remote = SkyPhoneMedia.VerifyRemoteUpload(
|
||||||
|
state,
|
||||||
|
data.remoteId,
|
||||||
|
data.url
|
||||||
|
)
|
||||||
if not verified then
|
if not verified then
|
||||||
pending_uploads[request_id] = nil
|
pending_uploads[request_id] = nil
|
||||||
if trusted_remote then
|
if trusted_remote then
|
||||||
|
|||||||
@@ -958,7 +958,6 @@ Bridge.Debug(
|
|||||||
)
|
)
|
||||||
|
|
||||||
Bridge.Callbacks.Register("sky_phone:device:close", function(source)
|
Bridge.Callbacks.Register("sky_phone:device:close", function(source)
|
||||||
SkyPhoneCompanies.ClearCallAvailability(source)
|
|
||||||
sessions[source] = nil
|
sessions[source] = nil
|
||||||
return { success = true }
|
return { success = true }
|
||||||
end)
|
end)
|
||||||
|
|||||||
Reference in New Issue
Block a user