Compare commits

..

13 Commits

Author SHA1 Message Date
DerEchteAlec 3314fa5355 Merge branch 'dev' into feat/refine-phone-setup 2026-08-20 18:25:45 +02:00
DerEchteAlec 637168c616 Merge branch 'dev' into feat/refine-phone-setup 2026-08-20 17:26:47 +02:00
DerEchteAlec 39fe171961 Merge branch 'dev' into feat/refine-phone-setup 2026-08-20 17:17:17 +02:00
smx.pusha c2fe8b5e23 FIX - correct messages GIF picker layout 2026-08-20 12:52:14 +02:00
smx.pusha a24cda989a ENH - apply liquid glass across app controls 2026-08-20 12:38:46 +02:00
smx.pusha b9f8930f00 ENH - refine phone liquid glass controls 2026-08-20 11:50:41 +02:00
smx.pusha 5efd806e43 ENH - refine notes header and compose controls 2026-08-20 11:02:44 +02:00
smx.pusha 65a0846eea FIX - align memo player content 2026-08-20 10:46:09 +02:00
smx.pusha 0a0053c3af ENH - align gallery filter navigation 2026-08-20 10:03:18 +02:00
smx.pusha 2f0faa8206 ENH - fade empty music widget content 2026-08-20 09:48:56 +02:00
smx.pusha 76b0378649 FIX - refine compact weather layout 2026-08-20 09:25:34 +02:00
smx.pusha 07b348a01a FIX - refine settings header and search icon 2026-08-20 08:45:58 +02:00
smx.pusha 9abf53b140 ENH - refine phone setup experience 2026-08-20 08:25:54 +02:00
46 changed files with 371 additions and 2457 deletions
+6 -78
View File
@@ -56,7 +56,6 @@ import { useNotesStore } from '@/stores/notes'
import { useMemosStore } from '@/stores/memos'
import { useWeatherStore } from '@/stores/weather'
import { useEasyShareStore } from '@/stores/easyshare'
import { useRadioStore } from '@/stores/radio'
import {
useNotificationsStore,
type PhoneNotification,
@@ -71,15 +70,10 @@ import type {
CompanyUnreadCounts,
} from '@/types/companies'
import type { PhoneCall } from '@/types/phone'
import type { DynamicIslandActivity } from '@/types/dynamicIsland'
import type { EasyShareEvent } from '@/types/easyshare'
import type { CryptoMarketChangedData } from '@/types/crypto'
import type { CityWarnEventData } from '@/types/citywarn'
import { nuiCall } from '@/utils/nui'
import {
installPhoneAudioController,
setPhoneOutputVolume,
} from '@/utils/phoneAudio'
import { formatTimer } from '@/utils/clock'
import { parsePhonePreferences } from '@/utils/preferences'
import { getHairlinePixelStyle } from '@/utils/rendering'
@@ -88,7 +82,6 @@ import { isTrustedRootMessageSource } from '@/utils/windowMessages'
import SpringboardView from '@/views/SpringboardView.vue'
type AppMessage = {
openHome?: boolean
type?: string
data?:
| CalendarReminderData
@@ -320,7 +313,6 @@ const notes = useNotesStore()
const memos = useMemosStore()
const weather = useWeatherStore()
const easyShare = useEasyShareStore()
const radio = useRadioStore()
const notifications = useNotificationsStore()
const route = useRoute()
const router = useRouter()
@@ -343,13 +335,8 @@ const DARK_STATUS_BAR_APP_IDS = new Set([
'minesweeper',
'number-merge',
])
const isDynamicIslandGalleryRoute = computed(
() => isDevelopment && route.name === 'development-dynamic-islands',
)
const isDevelopmentRoute = computed(
() =>
isDevelopment &&
(route.name === 'development-sky-ui' || isDynamicIslandGalleryRoute.value),
() => isDevelopment && route.name === 'development-sky-ui',
)
const appTransitionName = computed(() =>
route.query.transition === 'app-switch' ? 'app-switch' : 'app-window',
@@ -369,12 +356,9 @@ const setupPreviewDismissed = ref(false)
const setupDevelopmentSkipped = ref(false)
const setupAppearanceSelected = ref(false)
const pendingUnlockRoute = ref<string | null>(null)
const openHomeRequested = ref(false)
const unlockedServicesLoaded = ref(false)
const controlCenterOpened = ref(false)
const activitySuspended = ref(false)
const dynamicIslandExpanded = ref(false)
const dynamicIslandActivity = ref<DynamicIslandActivity | null>(null)
const simPicker = ref<SimPickerPayload | null>(null)
const setupRequired = computed(
() =>
@@ -448,12 +432,6 @@ const phoneResolutionStyle = computed<CSSProperties>(() => ({
}))
const phoneStageStyle = computed<CSSProperties>(() => ({
...phoneResolutionStyle.value,
'--phone-live-activity-peek-height':
dynamicIslandActivity.value === 'music'
? '190px'
: dynamicIslandActivity.value === 'recording'
? '132px'
: '112px',
visibility: activitySuspended.value ? 'hidden' : 'visible',
}))
const phoneDisplayStyle = computed<CSSProperties>(() => ({
@@ -471,7 +449,6 @@ let unlockTimer: number | undefined
let passcodeLockTimer: number | undefined
let hardwareVolumeHudTimer: number | undefined
let unlockedServicesIdle: number | undefined
let removePhoneAudioController: (() => void) | undefined
let phoneClosePending = false
let simPickerClosePending = false
@@ -781,7 +758,6 @@ function onMessage(event: MessageEvent<AppMessage>): void {
})
}
} else if (event.data?.type === 'app:open') {
openHomeRequested.value = event.data.openHome === true
hydratePhone(event.data.data as PhoneOpenPayload)
void syncNavigationState().then(() => nuiCall('ui:opened'))
} else if (event.data?.type === 'device:updated') {
@@ -1497,7 +1473,6 @@ function onFocusOut(event: FocusEvent): void {
}
onMounted(() => {
removePhoneAudioController = installPhoneAudioController()
document.addEventListener('focusin', onFocusIn)
document.addEventListener('focusout', onFocusOut)
window.addEventListener('message', onMessage)
@@ -1612,23 +1587,6 @@ 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(
() => phone.isOpen,
(isOpen) => {
@@ -1661,8 +1619,7 @@ watch(
}
isLocked.value = setupRequired.value
? false
: developmentLockScreenPreview ||
(!isDevelopment && phone.security.enabled)
: !isDevelopment || developmentLockScreenPreview
passcodeRequired.value = isLocked.value && phone.security.enabled
unlockedServicesLoaded.value = false
controlCenterOpened.value = false
@@ -1680,18 +1637,8 @@ watch(
startPasscodeLock(passcodeRetrySeconds.value)
}
phone.setLaunchOrigin(null)
if (setupRequired.value) {
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()
}
if (isLocked.value || setupRequired.value) void router.replace('/')
else loadUnlockedPhoneData()
},
)
@@ -1703,7 +1650,6 @@ watch(
)
onBeforeUnmount(() => {
removePhoneAudioController?.()
updateTextInputFocus(false)
cancelUnlockedPhoneDataLoad()
weather.stop()
@@ -1742,15 +1688,12 @@ onBeforeUnmount(() => {
phone.isOpen ||
notifications.current ||
calls.activeCall ||
dynamicIslandActivity ||
notifications.devicePreviews.length
"
class="phone-stage"
:class="{
'phone-stage--browser-preview': isBrowserPreview,
'phone-stage--landscape': phone.cameraLandscape,
'phone-stage--live-activity':
!phone.isOpen && Boolean(dynamicIslandActivity || calls.activeCall),
'phone-stage--peek': notifications.isPeeking,
}"
:style="phoneStageStyle"
@@ -1770,25 +1713,14 @@ onBeforeUnmount(() => {
@open="openNotificationPreview"
/>
<div
v-if="
phone.isOpen ||
notifications.current ||
calls.activeCall ||
dynamicIslandActivity
"
v-if="phone.isOpen || notifications.current || calls.activeCall"
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">
<section
class="phone-device"
:class="{
'phone-app--light': !displayedDarkMode,
'phone-device--island-expanded': dynamicIslandExpanded,
[`phone-app--${phone.preferences.settings.graphicsMode}`]: true,
}"
:aria-label="phone.t('Common.phone')"
@@ -1900,6 +1832,7 @@ onBeforeUnmount(() => {
@control-center="toggleControlCenter"
@lock="lockPhone"
/>
<PhoneDynamicIsland v-if="!setupRequired" />
<SpringboardView
v-if="!isDevelopmentRoute && !setupRequired"
@edit-mode-change="springboardEditing = $event"
@@ -1985,11 +1918,6 @@ onBeforeUnmount(() => {
aria-hidden="true"
draggable="false"
/>
<PhoneDynamicIsland
v-if="!setupRequired && !isDynamicIslandGalleryRoute"
@expanded-change="dynamicIslandExpanded = $event"
@live-activity-change="dynamicIslandActivity = $event"
/>
</section>
</div>
</div>
@@ -2,7 +2,7 @@ import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
const source = readFileSync(new URL('./App.vue', import.meta.url), 'utf8').replace(/\r\n/g, '\n')
const source = readFileSync(new URL('./App.vue', import.meta.url), 'utf8')
const mainCss = readFileSync(
new URL('./assets/main.css', import.meta.url),
'utf8',
@@ -22,32 +22,15 @@ describe('browser development preview contract', () => {
it('starts unlocked while preserving an explicit lock screen preview', () => {
expect(source).toContain("developmentParameters.has('lockScreenPreview')")
expect(source).toContain(
'developmentLockScreenPreview ||\n (!isDevelopment && phone.security.enabled)',
)
expect(source).toContain(': !isDevelopment || developmentLockScreenPreview')
expect(source).toContain("developmentParameters.has('setupPreview')")
})
it('restores the active route after the lock screen', () => {
expect(source).toMatch(
/if \(setupRequired\.value\) \{[\s\S]*?router\.replace\('\/'\)/,
)
expect(source).toMatch(
/else if \(!isLocked\.value\) \{\s*loadUnlockedPhoneData\(\)/,
)
expect(source).not.toContain(
it('loads authenticated app data without replacing direct app routes', () => {
expect(source).toContain(
"if (isLocked.value || setupRequired.value) void router.replace('/')",
)
})
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('/')")
expect(source).toContain('else loadUnlockedPhoneData()')
})
it('requires the passcode again after a full device lock', () => {
@@ -92,10 +75,6 @@ describe('browser development preview contract', () => {
it('uses layout zoom so the fixed-resolution phone stays sharply rasterized', () => {
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-width'")
expect(mainCss).toMatch(
@@ -121,9 +100,7 @@ describe('browser development preview contract', () => {
expect(source).toContain("developmentParameters.has('browserPreview')")
expect(source).toContain('import.meta.env.DEV ||')
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(
/\.phone-stage--browser-preview\s*\{[^}]*place-items:\s*center;[^}]*padding:\s*0;/s,
)
+75 -57
View File
@@ -259,20 +259,6 @@ button {
transform: translateY(calc(100% - 190px));
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 {
transition: opacity 0.52s linear;
}
@@ -295,18 +281,6 @@ button {
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 {
position: absolute;
top: 0;
@@ -410,7 +384,7 @@ button {
overflow: hidden;
border: 1px solid rgb(255 255 255 / 16%);
border-radius: 15px;
color: #a9abb2;
color: #0a84ff;
background: rgb(47 47 51 / 98%);
box-shadow: 0 8px 24px rgb(0 0 0 / 28%);
pointer-events: none;
@@ -698,9 +672,6 @@ button {
transition: transform 280ms var(--sky-ease-out, ease-out);
will-change: transform;
}
.phone-device--island-expanded .phone-notification {
top: 130px !important;
}
.phone-notification__icon {
width: 38px;
height: 38px;
@@ -824,6 +795,78 @@ button {
cursor: pointer;
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 {
position: absolute;
z-index: 90;
@@ -2634,30 +2677,8 @@ button {
.springboard--widget-dragging .springboard-widget-page-scroll {
overflow: visible;
}
.home-drag-layer {
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;
.springboard--home-dragging .springboard-page--apps {
overflow: visible;
}
.app-grid {
display: grid;
@@ -2692,9 +2713,6 @@ button {
transition: transform var(--springboard-page-duration)
var(--springboard-page-easing);
}
.app-icon-item--drag-source {
opacity: 0;
}
.app-icon-remove {
position: absolute;
z-index: 4;
@@ -14,10 +14,6 @@ const callsServer = readFileSync(
new URL('../../sky_phone/source/server/calls.lua', import.meta.url),
'utf8',
).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(
new URL('./stores/companies.ts', import.meta.url),
'utf8',
@@ -89,35 +85,3 @@ describe('Companies outbound service-line call contract', () => {
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)')
})
})
+2 -5
View File
@@ -30,13 +30,11 @@ const props = withDefaults(
app: PhoneAppDefinition
compact?: boolean
editMode?: boolean
externalDragVisual?: boolean
showLabel?: boolean
}>(),
{
compact: false,
editMode: false,
externalDragVisual: false,
showLabel: true,
},
)
@@ -65,14 +63,14 @@ let dragStartPage = 0
let dragPageWidth = 0
let dragPageMetrics: SpringboardDragMetrics | null = null
const dragStyle = computed(() =>
isDragging.value && !props.externalDragVisual
isDragging.value
? {
transform: `translate3d(${springboardPageDragCompensation(dragStartPage, phone.currentPage, dragPageWidth)}px, 0, 0)`,
}
: undefined,
)
const dragPointerStyle = computed(() =>
isDragging.value && !props.externalDragVisual
isDragging.value
? {
transform: `translate3d(${dragOffset.value.x}px, ${dragOffset.value.y}px, 0)`,
}
@@ -302,7 +300,6 @@ onBeforeUnmount(() => {
class="app-icon-item"
:class="{
'app-icon-item--compact': compact,
'app-icon-item--drag-source': isDragging && externalDragVisual,
'app-icon-item--dragging': isDragging,
'app-icon-item--editing': editMode,
}"
+2 -5
View File
@@ -22,13 +22,11 @@ const props = withDefaults(
apps: PhoneAppDefinition[]
defaultName: string
editMode?: boolean
externalDragVisual?: boolean
folder: HomeFolder
showLabel?: boolean
}>(),
{
editMode: false,
externalDragVisual: false,
showLabel: true,
},
)
@@ -57,14 +55,14 @@ let stopPointerSession: (() => void) | null = null
const folderName = computed(() => props.folder.name || props.defaultName)
const dragStyle = computed(() =>
isDragging.value && !props.externalDragVisual
isDragging.value
? {
transform: `translate3d(${springboardPageDragCompensation(dragStartPage, phone.currentPage, dragPageWidth)}px, 0, 0)`,
}
: undefined,
)
const dragPointerStyle = computed(() =>
isDragging.value && !props.externalDragVisual
isDragging.value
? {
transform: `translate3d(${dragOffset.value.x}px, ${dragOffset.value.y}px, 0)`,
}
@@ -234,7 +232,6 @@ onBeforeUnmount(() => {
<div
class="home-folder-item app-icon-item"
:class="{
'app-icon-item--drag-source': isDragging && externalDragVisual,
'app-icon-item--dragging': isDragging,
'app-icon-item--editing': editMode,
'home-folder-item--dragging': isDragging,
+1 -4
View File
@@ -3,7 +3,6 @@ import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
import payphoneFrame from '@/assets/img/payphone/american-payphone-frame.png'
import { nuiCall } from '@/utils/nui'
import { registerPhoneMediaElement } from '@/utils/phoneAudio'
import { isTrustedRootMessageSource } from '@/utils/windowMessages'
type PayphoneState =
@@ -71,9 +70,7 @@ const buttonSounds: HTMLAudioElement[] = []
function prepareButtonSounds(): void {
if (buttonSounds.length) return
for (let index = 0; index < 4; index += 1) {
const sound = registerPhoneMediaElement(
new Audio(`${import.meta.env.BASE_URL}sounds/button.mp3`),
)
const sound = new Audio(`${import.meta.env.BASE_URL}sounds/button.mp3`)
sound.preload = 'auto'
sound.volume = 0.55
buttonSounds.push(sound)
@@ -5,15 +5,8 @@ import { describe, expect, it } from 'vitest'
const source = readFileSync(
new URL('./PhoneDynamicIsland.vue', import.meta.url),
'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(
new URL('./PhoneMemoRecorder.vue', import.meta.url),
'utf8',
@@ -26,23 +19,16 @@ const clockSource = readFileSync(
new URL('../views/apps/ClockApp.vue', import.meta.url),
'utf8',
)
const serverMemosSource = readFileSync(
new URL('../../../sky_phone/source/server/memos.lua', import.meta.url),
'utf8',
)
describe('Phone Dynamic Island contract', () => {
it('renders once at phone shell level instead of forcing calls into Phone', () => {
expect(appSource).toContain(
"import PhoneDynamicIsland from '@/components/PhoneDynamicIsland.vue'",
)
expect(appSource).toContain('<PhoneDynamicIsland v-if="!setupRequired" />')
expect(appSource).toContain(
"'phone-device--island-expanded': dynamicIslandExpanded",
'phone.isOpen || notifications.current || calls.activeCall',
)
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(
"window.setTimeout(() => void router.push('/apps/phone'), 0)",
)
@@ -59,44 +45,7 @@ describe('Phone Dynamic Island contract', () => {
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', () => {
expect(source).toContain(
"if (music.isPlaying && music.currentTrack) return 'music'",
)
expect(source).toContain('@click.stop="music.previous()"')
expect(source).toContain('@click.stop="music.toggle()"')
expect(source).toContain('@click.stop="music.next()"')
@@ -104,31 +53,9 @@ describe('Phone Dynamic Island contract', () => {
expect(source).toContain('clock.pauseTimer(Date.now())')
expect(source).toContain('clock.pauseStopwatch(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('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', () => {
it('keeps recorder state available across app changes', () => {
expect(recorderSource).toContain(
"message.type === 'memo:recordStateRequest'",
)
@@ -138,15 +65,6 @@ describe('Phone Dynamic Island contract', () => {
expect(memosSource).not.toContain(
"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', () => {
@@ -157,68 +75,13 @@ 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', () => {
expect(source).toContain('<Transition name="phone-dynamic-island">')
expect(source).toContain(
'<Transition name="phone-dynamic-island-content" mode="out-in">',
)
expect(source).toContain(".phone-dynamic-island[data-expanded='true']")
expect(source).toContain("emit('expanded-change', false)")
expect(mainCss).toContain(
'.phone-device--island-expanded .phone-notification',
)
expect(source).toContain('~ .phone-notification-provider')
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
+13 -10
View File
@@ -54,7 +54,6 @@ let metadata: MemoRecordingMetadata = { note: '', pinned: false, title: '' }
let currentState: MemoRecorderStateName = 'idle'
let currentElapsedMs = 0
let currentCorrelationId = ''
let recordingDeviceImei = ''
let removeRecorderErrorListener: (() => void) | null = null
function postRecorderState(state: MemoRecorderStateName, error?: string): void {
@@ -154,7 +153,6 @@ function resetRecordingData(): void {
pausedStartedAt = 0
totalPausedMs = 0
currentElapsedMs = 0
recordingDeviceImei = ''
liveLevels = Array(LIVE_LEVEL_SAMPLES).fill(0.08)
}
@@ -191,8 +189,7 @@ function failRecording(error: string, generation = recordingGeneration): void {
}
async function startRecording(data: Record<string, unknown>): Promise<void> {
const deviceImei = phone.device?.imei
if (!phone.isOpen || !deviceImei) {
if (!phone.isOpen) {
console.error('[Memos] Cannot start a recording while the phone is closed.')
return
}
@@ -220,7 +217,6 @@ async function startRecording(data: Record<string, unknown>): Promise<void> {
metadata = { note: '', pinned: false, title: '' }
updateMetadata(data)
resetRecordingData()
recordingDeviceImei = deviceImei
postRecorderState('starting')
try {
const acquiredStream = await navigator.mediaDevices.getUserMedia({
@@ -344,7 +340,6 @@ async function stopRecording(data: Record<string, unknown>): Promise<void> {
if (generation !== recordingGeneration) return
const waveform = compressedWaveform()
const finalMetadata = { ...metadata }
const finalDeviceImei = recordingDeviceImei
const exceededSizeLimit = recordingTooLarge
cleanupRecorder(false)
resetRecordingData()
@@ -360,7 +355,6 @@ async function stopRecording(data: Record<string, unknown>): Promise<void> {
liveLevels = waveform.slice(-LIVE_LEVEL_SAMPLES)
const uploadData = {
correlationId,
deviceImei: finalDeviceImei,
durationMs,
mimeType,
note: finalMetadata.note,
@@ -575,9 +569,18 @@ function onMessage(event: MessageEvent): void {
onMounted(() => window.addEventListener('message', onMessage))
watch(
() => phone.device?.imei ?? null,
(imei, previousImei) => {
if (previousImei && imei !== previousImei) cancelRecording()
() =>
[
phone.isOpen,
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()
},
)
+1 -6
View File
@@ -1,5 +1,3 @@
import { getPhoneOutputVolume } from '@/utils/phoneAudio'
export type MemorySound = 'flip' | 'match' | 'mismatch' | 'win'
type Tone = {
@@ -48,10 +46,7 @@ export function playMemorySound(sound: MemorySound, enabled: boolean): void {
oscillator.type = tone.type
oscillator.frequency.setValueAtTime(tone.frequency, start)
gain.gain.setValueAtTime(0.0001, start)
gain.gain.exponentialRampToValueAtTime(
Math.max(0.0001, tone.volume * getPhoneOutputVolume()),
start + 0.012,
)
gain.gain.exponentialRampToValueAtTime(tone.volume, start + 0.012)
gain.gain.exponentialRampToValueAtTime(0.0001, end)
oscillator.connect(gain)
gain.connect(audioContext.destination)
@@ -1,5 +1,3 @@
import { registerPhoneMediaElement } from '@/utils/phoneAudio'
import flagUrl from '@/assets/audio/minesweeper/flag.wav?url'
import clearUrl from '@/assets/audio/minesweeper/clear.wav?url'
import mineUrl from '@/assets/audio/minesweeper/mine.wav?url'
@@ -30,7 +28,7 @@ function getPlayers(sound: MinesweeperSound): HTMLAudioElement[] {
if (existing) return existing
const players = Array.from({ length: 3 }, () => {
const player = registerPhoneMediaElement(new Audio(soundUrls[sound]))
const player = new Audio(soundUrls[sound])
player.preload = 'auto'
player.volume = 0.82
return player
@@ -1,5 +1,3 @@
import { getPhoneOutputVolume } from '@/utils/phoneAudio'
export type NeonDropSound =
| 'clear'
| 'drop'
@@ -50,10 +48,7 @@ function playSequence(sound: NeonDropSound): void {
oscillator.type = type
oscillator.frequency.setValueAtTime(frequency, start + delay)
gain.gain.setValueAtTime(0.0001, start + delay)
gain.gain.exponentialRampToValueAtTime(
Math.max(0.0001, 0.12 * getPhoneOutputVolume()),
start + delay + 0.008,
)
gain.gain.exponentialRampToValueAtTime(0.12, start + delay + 0.008)
gain.gain.exponentialRampToValueAtTime(0.0001, start + delay + duration)
oscillator.connect(gain)
gain.connect(context.destination)
@@ -1,5 +1,3 @@
import { registerPhoneMediaElement } from '@/utils/phoneAudio'
import gameOverUrl from '@/assets/audio/number-merge/game-over.wav?url'
import mergeUrl from '@/assets/audio/number-merge/merge.wav?url'
import moveUrl from '@/assets/audio/number-merge/move.wav?url'
@@ -20,7 +18,7 @@ function getPlayers(sound: NumberMergeSound): HTMLAudioElement[] {
if (existing) return existing
const players = Array.from({ length: 3 }, () => {
const player = registerPhoneMediaElement(new Audio(soundUrls[sound]))
const player = new Audio(soundUrls[sound])
player.preload = 'auto'
player.volume = 0.82
return player
@@ -1,5 +1,3 @@
import { registerPhoneMediaElement } from '@/utils/phoneAudio'
import crashUrl from '@/assets/audio/sky-flappy/crash.wav?url'
import flapUrl from '@/assets/audio/sky-flappy/flap.wav?url'
import pointUrl from '@/assets/audio/sky-flappy/point.wav?url'
@@ -13,7 +11,7 @@ export function playSkyFlappySound(sound: SkyFlappySound, enabled: boolean): voi
let players = pools.get(sound)
if (!players) {
players = Array.from({ length: 3 }, () => {
const player = registerPhoneMediaElement(new Audio(urls[sound]))
const player = new Audio(urls[sound])
player.preload = 'auto'
player.volume = 0.84
return player
@@ -1,5 +1,3 @@
import { registerPhoneMediaElement } from '@/utils/phoneAudio'
import fallUrl from '@/assets/audio/tower-stack/fall.wav?url'
import hitUrl from '@/assets/audio/tower-stack/hit.wav?url'
import perfectUrl from '@/assets/audio/tower-stack/perfect.wav?url'
@@ -20,7 +18,7 @@ function getPlayers(sound: TowerStackSound): HTMLAudioElement[] {
if (existing) return existing
const players = Array.from({ length: 3 }, () => {
const player = registerPhoneMediaElement(new Audio(soundUrls[sound]))
const player = new Audio(soundUrls[sound])
player.preload = 'auto'
player.volume = 0.84
return player
-37
View File
@@ -1,37 +0,0 @@
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;/)
})
})
+1 -17
View File
@@ -3,7 +3,7 @@ import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
const readResourceFile = (path: string) =>
readFileSync(new URL(`../../sky_phone/${path}`, import.meta.url), 'utf8').replace(/\r\n/g, '\n')
readFileSync(new URL(`../../sky_phone/${path}`, import.meta.url), 'utf8')
const readFrontendFile = (path: string) =>
readFileSync(new URL(path, import.meta.url), 'utf8')
@@ -232,22 +232,6 @@ 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', () => {
const phoneServer = readResourceFile('source/server/phone.lua')
-6
View File
@@ -15,12 +15,6 @@ const developmentRoutes: RouteRecordRaw[] = import.meta.env.DEV
name: 'development-sky-ui',
path: '/development/sky-ui/:demo?',
},
{
component: () =>
import('@/views/development/PhoneDynamicIslandGallery.vue'),
name: 'development-dynamic-islands',
path: '/development/dynamic-islands',
},
]
: []
+6 -41
View File
@@ -64,41 +64,6 @@ describe('app store', () => {
expect(apps.isInstalled('snake')).toBe(false)
expect(apps.isInstalled('health')).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', () => {
@@ -355,7 +320,7 @@ describe('app store', () => {
const apps = useAppStoreStore()
apps.hydrate(null)
mocks.phone.saveDeviceNamespace.mockClear()
const sourceIndex = apps.homeLayout.grid.indexOf('calculator')
const sourceIndex = apps.homeLayout.grid.indexOf('phone')
expect(sourceIndex).toBeGreaterThanOrEqual(0)
expect(
@@ -364,7 +329,7 @@ describe('app store', () => {
HOME_GRID_PAGE_SIZE,
]),
).toBe(true)
expect(apps.homeLayout.grid[HOME_GRID_PAGE_SIZE]).toBe('calculator')
expect(apps.homeLayout.grid[HOME_GRID_PAGE_SIZE]).toBe('phone')
expect(mocks.phone.saveDeviceNamespace).toHaveBeenCalledTimes(1)
})
@@ -421,18 +386,18 @@ describe('app store', () => {
mocks.phone.saveDeviceNamespace.mockClear()
const notesIndex = apps.homeLayout.grid.indexOf('notes')
const settingsIndex = apps.homeLayout.grid.indexOf('settings')
const clockIndex = apps.homeLayout.grid.indexOf('clock')
const folderId = apps.createHomeFolder(
'grid',
notesIndex,
'grid',
settingsIndex,
clockIndex,
'Utilities',
)
expect(folderId).toBeTruthy()
expect(getHomeFolder(apps.homeLayout, folderId!)?.apps).toEqual([
'settings',
'clock',
'notes',
])
const mailIndex = apps.homeLayout.grid.indexOf('mail')
@@ -440,7 +405,7 @@ describe('app store', () => {
apps.moveHomeFolderApp(folderId!, 2, 0)
apps.renameHomeFolder(folderId!, 'Work')
expect(getHomeFolder(apps.homeLayout, folderId!)).toMatchObject({
apps: ['mail', 'notes', 'settings'],
apps: ['mail', 'notes', 'clock'],
name: 'Work',
})
+7 -10
View File
@@ -25,7 +25,6 @@ import {
parseHomeLayout,
reflowHomeGridForWidgetChange,
renameHomeFolder,
removeDockGridDuplicates,
removeHomeApp,
restoreHomeApp,
type HomeArea,
@@ -46,7 +45,7 @@ const pendingInstallations = new WeakMap<
>()
function getDefaultGridIds(): LaunchablePhoneAppId[] {
return PHONE_APPS.filter((app) => app.dockOrder === null)
return [...PHONE_APPS]
.sort((a, b) => a.gridOrder - b.gridOrder)
.map((app) => app.id)
}
@@ -264,16 +263,12 @@ export const useAppStoreStore = defineStore('app-store', {
getDefaultGridIds(),
getDefaultDockIds(),
)
const parsedHomeLayout = parseHomeLayout(
this.homeLayout = parseHomeLayout(
data?.homeLayout,
defaults,
installedIds,
false,
)
const normalizedHomeLayout = removeDockGridDuplicates(parsedHomeLayout)
const removedDockGridDuplicates =
normalizedHomeLayout !== parsedHomeLayout
this.homeLayout = normalizedHomeLayout
const protectedHiddenAppIds =
this.homeLayout.hidden.filter(isProtectedHomeApp)
for (const appId of protectedHiddenAppIds) {
@@ -297,7 +292,6 @@ export const useAppStoreStore = defineStore('app-store', {
this.hydrated = true
if (
protectedHiddenAppIds.length ||
removedDockGridDuplicates ||
removedLegacyDefaults ||
layoutVersion === 2 ||
layoutVersion === 3 ||
@@ -326,8 +320,11 @@ export const useAppStoreStore = defineStore('app-store', {
getDefaultDockIds(),
)
const previous = JSON.stringify(this.homeLayout)
this.homeLayout = removeDockGridDuplicates(
parseHomeLayout(this.homeLayout, defaults, installedIds, false),
this.homeLayout = parseHomeLayout(
this.homeLayout,
defaults,
installedIds,
false,
)
for (const appId of [...this.homeLayout.hidden]) {
+4 -16
View File
@@ -7,11 +7,6 @@ import type {
MusicTrack,
} from '@/types/music'
import { nuiCall } from '@/utils/nui'
import {
getPhoneOutputVolume,
registerPhoneMediaElement,
subscribePhoneOutputVolume,
} from '@/utils/phoneAudio'
export type YouTubePlayer = {
destroy: () => void
@@ -53,7 +48,7 @@ declare global {
}
}
const audio = registerPhoneMediaElement(new Audio())
const audio = new Audio()
audio.preload = 'auto'
const YOUTUBE_API_TIMEOUT_MS = 12000
let audioBound = false
@@ -64,11 +59,6 @@ let youtubeApiPromise: Promise<YouTubeApi> | null = null
let youtubePlayer: YouTubePlayer | null = null
let youtubeProgressTimer: number | null = null
subscribePhoneOutputVolume((volume) => {
const localVolume = useMusicStore().volume
youtubePlayer?.setVolume(localVolume * volume * 100)
})
export function musicTrackKey(
track: Pick<MusicTrack, 'id' | 'source'>,
): string {
@@ -286,7 +276,7 @@ async function loadYouTubeTrack(videoId: string): Promise<void> {
const api = await loadYouTubeApi()
const store = useMusicStore()
if (youtubePlayer) {
youtubePlayer.setVolume(store.volume * getPhoneOutputVolume() * 100)
youtubePlayer.setVolume(store.volume * 100)
youtubePlayer.loadVideoById(videoId)
youtubePlayer.playVideo()
startYoutubeProgress()
@@ -329,9 +319,7 @@ async function loadYouTubeTrack(videoId: string): Promise<void> {
},
onReady: (event) => {
youtubePlayer = event.target
event.target.setVolume(
useMusicStore().volume * getPhoneOutputVolume() * 100,
)
event.target.setVolume(useMusicStore().volume * 100)
event.target.playVideo()
startYoutubeProgress()
resolve()
@@ -566,7 +554,7 @@ export const useMusicStore = defineStore('music', {
setVolume(value: number): void {
this.volume = Math.max(0, Math.min(1, value))
audio.volume = this.volume
youtubePlayer?.setVolume(this.volume * getPhoneOutputVolume() * 100)
youtubePlayer?.setVolume(this.volume * 100)
},
stop(): void {
stopActiveMedia()
-7
View File
@@ -1,7 +0,0 @@
export type DynamicIslandActivity =
| 'call'
| 'incoming-call'
| 'music'
| 'recording'
| 'stopwatch'
| 'timer'
-35
View File
@@ -21,7 +21,6 @@ import {
moveHomeFolderApp,
parseHomeLayout,
reflowHomeGridForWidgetChange,
removeDockGridDuplicates,
removeHomeApp,
renameHomeFolder,
restoreHomeApp,
@@ -79,40 +78,6 @@ describe('home layout', () => {
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', () => {
const layout = parseHomeLayout(
{
-31
View File
@@ -505,37 +505,6 @@ 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(
value: unknown,
defaults: HomeLayout,
@@ -1,5 +1,4 @@
import { describe, expect, it } from 'vitest'
import { runInNewContext } from 'node:vm'
import type { ExternalPhoneAppDefinition } from '@/types/apps'
import {
@@ -140,79 +139,6 @@ describe('LB Phone app bridge', () => {
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', () => {
const values = new Map<string, string>()
const storage = {
+1 -28
View File
@@ -130,25 +130,6 @@ function applySettings(nextSettings) {
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.appName = config.appName;
globalThis.components = globalThis.components ?? {};
@@ -208,11 +189,6 @@ globalThis.getSettings = async () => globalThis.settings;
globalThis.addEventListener('message', (event) => {
const message = event.data;
if (message === 'componentsLoaded') {
globalThis.componentsLoaded = true;
prepareDocument();
return;
}
if (!message || typeof message !== 'object') return;
if (message.type === 'sky-phone:lb-settings') {
@@ -234,10 +210,7 @@ globalThis.addEventListener('message', (event) => {
});
applySettings(config.settings);
document.addEventListener('DOMContentLoaded', () => {
prepareDocument();
applySettings(globalThis.settings);
}, { once: true });
document.addEventListener('DOMContentLoaded', () => applySettings(globalThis.settings), { once: true });
`
function escapeAttribute(value: string): string {
-96
View File
@@ -1,96 +0,0 @@
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)
}
@@ -1,174 +0,0 @@
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()
})
})
@@ -1,73 +0,0 @@
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,56 +140,4 @@ describe('springboard widget drag', () => {
expect(delta.x).toBeCloseTo(100)
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 -5
View File
@@ -1,5 +1,3 @@
import { readPhoneViewportGeometry } from '@/utils/phoneViewportGeometry'
export type PageTurnDirection = -1 | 0 | 1
export type SpringboardEdgeTurn = {
@@ -41,9 +39,7 @@ export function readSpringboardDragMetrics(
'.springboard-page, .home-folder-panel',
)
if (!surface) return null
const bounds =
readPhoneViewportGeometry(surface)?.rect(surface) ??
surface.getBoundingClientRect()
const bounds = surface.getBoundingClientRect()
return {
layoutHeight: surface.offsetHeight,
layoutWidth: surface.offsetWidth,
+1 -2
View File
@@ -32,7 +32,7 @@ describe('phone tones', () => {
}> = []
vi.stubGlobal(
'Audio',
class extends EventTarget {
class {
currentTime = 7
loop = false
pause = pause
@@ -42,7 +42,6 @@ describe('phone tones', () => {
volume = 0
constructor(src: string) {
super()
this.src = src
players.push(this)
}
+2 -3
View File
@@ -1,4 +1,3 @@
import { registerPhoneMediaElement } from '@/utils/phoneAudio'
import type { AlarmSoundId } from '@/utils/alarms'
import type { NotificationSoundId } from '@/utils/preferences'
@@ -496,8 +495,8 @@ export function playPhoneVibration(
kind: PhoneVibrationKind,
loop: boolean,
): () => void {
const player = registerPhoneMediaElement(
new Audio(`${import.meta.env.BASE_URL}${VIBRATION_SOUND_PATHS[kind]}`),
const player = new Audio(
`${import.meta.env.BASE_URL}${VIBRATION_SOUND_PATHS[kind]}`,
)
let stopped = false
player.loop = loop
@@ -94,99 +94,36 @@ describe('Springboard page swipe contract', () => {
expect(folderIconSource).toContain(':style="dragPointerStyle"')
})
it('renders the home drag visual through the unzoomed phone portal', () => {
expect(viewSource).toContain('source.cloneNode(true)')
it('uses the same scale-aware in-phone drag path as widgets', () => {
expect(viewSource).not.toContain('source.cloneNode(true)')
expect(viewSource).not.toContain('<Teleport to="body">')
expect(viewSource).not.toContain(':external-drag-visual')
expect(viewSource).toContain(
'<Teleport defer to="#phone-home-drag-portal">',
"'springboard--home-dragging': draggingHomeApp !== null",
)
expect(viewSource).toContain(
'<div ref="homeDragLayer" class="home-drag-layer" aria-hidden="true"></div>',
)
expect(viewSource).toContain('readPhoneViewportGeometry')
expect(viewSource).toMatch(/const sourceBounds = \w+\.rect\(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(viewSource).toContain('draggedElement?.getBoundingClientRect()')
expect(appIconSource).not.toContain('externalDragVisual')
expect(folderIconSource).not.toContain('externalDragVisual')
expect(mainCss).not.toContain('.home-drag-layer')
expect(mainCss).not.toContain('.home-drag-ghost')
expect(mainCss).not.toContain('.app-icon-item--drag-source')
expect(mainCss).toMatch(
/\.phone-home-drag-portal\s*\{[^}]*pointer-events:\s*none;/s,
/\.springboard--home-dragging \.springboard-page--apps\s*\{[^}]*overflow:\s*visible;/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-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', () => {
expect(viewSource).toContain(':data-home-page="page.page"')
expect(viewSource).toContain(':data-home-target-offset="cell.targetOffset"')
expect(viewSource).toContain('nearestGridDropTarget')
expect(viewSource).toContain('moveHomeAppToGridPage')
expect(viewSource).not.toContain("draggingHomeApp.value?.area === 'grid'")
expect(viewSource).toContain('event.clientX < springboardBounds.left')
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('event.clientX < pageBounds.left')
expect(viewSource).toContain('event.clientY > pageBounds.bottom')
expect(viewSource).toContain('nearestSpringboardRectIndex')
expect(viewSource).toContain("queueEdgePageTurn(lastHomePointer, 'app')")
expect(viewSource).toContain("edgePageLocked = dragType === 'widget'")
+35 -273
View File
@@ -41,11 +41,6 @@ import {
type HomeItem,
} from '@/utils/homeLayout'
import type { ReorderDirection } from '@/utils/keyboard'
import {
readPhoneViewportGeometry,
type PhoneViewportGeometry,
type PhoneViewportRect,
} from '@/utils/phoneViewportGeometry'
import { layoutSpringboardHomePages } from '@/utils/springboardLayout'
import {
maximumRenderedWidgetPage,
@@ -111,8 +106,6 @@ const draggingHomeApp = ref<{
area: HomeArea
index: number
} | null>(null)
const homeDragLayer = ref<HTMLElement | null>(null)
const homeDragVisualActive = ref(false)
const temporaryHomePage = ref<number | null>(null)
const openedFolderId = ref<string | null>(null)
const folderDraggingOutside = ref(false)
@@ -132,11 +125,6 @@ let edgePageDirection = 0
let edgePageLocked = false
let folderHoverTimer: number | undefined
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(() =>
PHONE_APPS.filter((app) => appStore.isInstalled(app.id)),
@@ -465,7 +453,7 @@ function startWidgetDrag(id: string, event: PointerEvent): void {
).find((candidate) => candidate.dataset.widgetId === id)
if (!widget) return
pageTransitioning.value = false
const bounds = homeDragViewportRect(widget)
const bounds = widget.getBoundingClientRect()
draggingWidgetId.value = id
widgetDragGrip.value = {
x: event.clientX - bounds.left,
@@ -486,7 +474,7 @@ function updateWidgetDragPreview(event: {
)
const pageElement = grid?.closest<HTMLElement>('.springboard-page')
if (!grid || !pageElement) return
const renderedGridBounds = homeDragViewportRect(grid)
const renderedGridBounds = grid.getBoundingClientRect()
const gridStyle = getComputedStyle(grid)
const scaleX =
grid.offsetWidth > 0 ? renderedGridBounds.width / grid.offsetWidth : 1
@@ -541,7 +529,7 @@ function clearTemporaryHomePage(keepCurrentPage = false): void {
function resolveHomeEdgeTurn(event: { clientX: number }) {
const springboard = document.querySelector<HTMLElement>('.springboard')
if (!springboard) return null
const bounds = homeDragViewportRect(springboard)
const bounds = springboard.getBoundingClientRect()
const renderedLastPage = appPages.value.length
return resolveSpringboardHomeEdgeTurn(
event.clientX,
@@ -560,7 +548,7 @@ function queueEdgePageTurn(
): void {
const springboard = document.querySelector<HTMLElement>('.springboard')
if (!springboard) return
const bounds = homeDragViewportRect(springboard)
const bounds = springboard.getBoundingClientRect()
const turn =
dragType === 'app'
? resolveHomeEdgeTurn(event)
@@ -745,166 +733,10 @@ async function saveWidgetConfig(
if (configured) changePage(configured.page)
}
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 {
function startHomeDrag(area: HomeArea, index: number): void {
clearTemporaryHomePage()
lastHomePointer = null
draggingHomeApp.value = { area, index }
createHomeDragGhost(event)
}
function clearFolderHover(): void {
@@ -922,40 +754,10 @@ function queueFolderHover(event: PointerEvent): void {
return
}
const targetElement = Array.from(
document.querySelectorAll<HTMLElement>('[data-home-area][data-home-index]'),
)
.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 targetElement = document
.elementsFromPoint(event.clientX, event.clientY)
.map((element) => element.closest<HTMLElement>('[data-home-index]'))
.find((element) => element && !element.closest('.app-icon-item--dragging'))
const area = targetElement?.dataset.homeArea as HomeArea | undefined
const targetIndex = Number(targetElement?.dataset.homeIndex)
if (
@@ -1020,7 +822,6 @@ function queueFolderHover(event: PointerEvent): void {
if (folderId) {
draggingHomeApp.value = null
lastHomePointer = null
clearHomeDragGhost()
clearTemporaryHomePage()
openedFolderId.value = folderId
}
@@ -1031,7 +832,6 @@ function queueFolderHover(event: PointerEvent): void {
function moveHomeDrag(event: PointerEvent): void {
if (!draggingHomeApp.value) return
lastHomePointer = { clientX: event.clientX, clientY: event.clientY }
updateHomeDragGhost(event)
if (resolveHomeEdgeTurn(event)) {
clearFolderHover()
queueEdgePageTurn(event, 'app')
@@ -1045,7 +845,7 @@ async function animateHomeItemDrop(
item: HomeItem,
area: HomeArea,
index: number,
from: PhoneViewportRect,
from: DOMRect,
): Promise<void> {
await nextTick()
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return
@@ -1061,10 +861,7 @@ async function animateHomeItemDrop(
)
if (!appElement) return
const target = homeDragViewportRect(
appElement,
readPhoneViewportGeometry(appElement),
)
const target = appElement.getBoundingClientRect()
const { x: offsetX, y: offsetY } = springboardViewportDeltaToLocal(
from.left - target.left,
from.top - target.top,
@@ -1127,16 +924,13 @@ function nearestGridDropTarget(event: {
`.springboard-page--apps[data-home-page="${page}"] .app-grid`,
)
const pageElement = grid?.closest<HTMLElement>('.springboard-page')
const springboard = grid?.closest<HTMLElement>('.springboard')
if (!grid || !pageElement || !springboard) return null
const geometry = homeDragGeometry ?? readPhoneViewportGeometry(springboard)
const pageBounds = homeDragViewportRect(pageElement, geometry)
const springboardBounds = homeDragViewportRect(springboard, geometry)
if (!grid || !pageElement) return null
const pageBounds = pageElement.getBoundingClientRect()
if (
event.clientX < springboardBounds.left ||
event.clientX > springboardBounds.right ||
event.clientY < springboardBounds.top ||
event.clientY > springboardBounds.bottom
event.clientX < pageBounds.left ||
event.clientX > pageBounds.right ||
event.clientY < pageBounds.top ||
event.clientY > pageBounds.bottom
) {
return null
}
@@ -1146,15 +940,7 @@ function nearestGridDropTarget(event: {
const closestIndex = nearestSpringboardRectIndex(
event.clientX,
event.clientY,
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,
}
}),
slots.map((slot) => slot.getBoundingClientRect()),
)
const closest = closestIndex === null ? null : slots[closestIndex]
const targetOffset = Number(closest?.dataset.homeTargetOffset)
@@ -1167,8 +953,7 @@ function nearestDockDropTarget(event: {
}): HTMLElement | null {
const dock = document.querySelector<HTMLElement>('.app-dock')
if (!dock) return null
const geometry = homeDragGeometry ?? readPhoneViewportGeometry(dock)
const dockBounds = homeDragViewportRect(dock, geometry)
const dockBounds = dock.getBoundingClientRect()
if (
event.clientX < dockBounds.left ||
event.clientX > dockBounds.right ||
@@ -1185,7 +970,7 @@ function nearestDockDropTarget(event: {
const closestIndex = nearestSpringboardRectIndex(
event.clientX,
event.clientY,
slots.map((slot) => homeDragViewportRect(slot, geometry)),
slots.map((slot) => slot.getBoundingClientRect()),
)
return closestIndex === null ? null : slots[closestIndex]
}
@@ -1194,14 +979,12 @@ function finishHomeDrag(event: PointerEvent): void {
clearEdgePageTurn()
clearFolderHover()
const dragged = draggingHomeApp.value
if (!dragged) {
clearHomeDragGhost()
return
}
updateHomeDragGhost(event)
if (!dragged) return
const draggedItem = appStore.homeLayout[dragged.area][dragged.index]
const dropGhost = homeDragGhost
const dropOrigin = homeDragPreviewBounds ? { ...homeDragPreviewBounds } : null
const draggedElement = document.querySelector<HTMLElement>(
`[data-home-area="${dragged.area}"][data-home-index="${dragged.index}"]`,
)
const dropOrigin = draggedElement?.getBoundingClientRect()
const dockTarget = nearestDockDropTarget(event)
const gridTarget = dockTarget ? null : nearestGridDropTarget(event)
let dropArea = dragged.area
@@ -1239,14 +1022,7 @@ function finishHomeDrag(event: PointerEvent): void {
lastHomePointer = null
clearTemporaryHomePage(moved && gridTarget !== null)
if (moved && draggedItem && dropOrigin) {
void animateHomeItemDrop(
draggedItem,
dropArea,
dropIndex,
dropOrigin,
).finally(() => clearHomeDragGhost(dropGhost))
} else {
clearHomeDragGhost()
void animateHomeItemDrop(draggedItem, dropArea, dropIndex, dropOrigin)
}
}
@@ -1255,7 +1031,6 @@ function stopHomeDrag(): void {
clearFolderHover()
draggingHomeApp.value = null
lastHomePointer = null
clearHomeDragGhost()
clearTemporaryHomePage()
}
@@ -1268,9 +1043,7 @@ function reorderHomeApp(
const appElement = document.querySelector<HTMLElement>(
`[data-home-area="${area}"][data-home-index="${sourceIndex}"]`,
)
const moveOrigin = appElement
? homeDragViewportRect(appElement, readPhoneViewportGeometry(appElement))
: null
const moveOrigin = appElement?.getBoundingClientRect()
const targetIndex = homeKeyboardTarget(
appStore.homeLayout,
area,
@@ -1358,10 +1131,7 @@ function extractOpenedFolderApp(
`.home-folder-panel [data-folder-app-index="${sourceIndex}"]`,
)
?.closest<HTMLElement>('.app-icon-item')
const geometry = readPhoneViewportGeometry(sourceElement ?? null)
const sourceBounds = sourceElement
? homeDragViewportRect(sourceElement, geometry)
: null
const sourceBounds = sourceElement?.getBoundingClientRect()
const candidates = Array.from(
document.querySelectorAll<HTMLElement>('[data-home-index][data-home-area]'),
).filter((element) => {
@@ -1375,8 +1145,8 @@ function extractOpenedFolderApp(
})
const target = candidates.reduce<HTMLElement | null>((closest, candidate) => {
if (!closest) return candidate
const bounds = homeDragViewportRect(candidate, geometry)
const closestBounds = homeDragViewportRect(closest, geometry)
const bounds = candidate.getBoundingClientRect()
const closestBounds = closest.getBoundingClientRect()
const distance = Math.hypot(
event.clientX - (bounds.left + bounds.width / 2),
event.clientY - (bounds.top + bounds.height / 2),
@@ -1442,7 +1212,6 @@ onBeforeUnmount(() => {
pendingWidgetPointer = null
lastWidgetPointer = null
lastHomePointer = null
clearHomeDragGhost()
temporaryHomePage.value = null
draggingHomeApp.value = null
draggingWidgetId.value = null
@@ -1458,6 +1227,7 @@ onBeforeUnmount(() => {
'springboard--dragging': dragging,
'springboard--editing': editMode,
'springboard--folder-open': folderOverlayVisible,
'springboard--home-dragging': draggingHomeApp !== null,
'springboard--widget-dragging': draggingWidgetId !== null,
},
]"
@@ -1533,7 +1303,6 @@ onBeforeUnmount(() => {
:data-home-index="cell.sourceIndex"
:data-home-target-offset="cell.targetOffset"
:edit-mode="editMode"
:external-drag-visual="homeDragVisualActive"
:class="{
'home-item--folder-hover':
folderHoverTarget === `grid:${cell.sourceIndex}`,
@@ -1541,7 +1310,7 @@ onBeforeUnmount(() => {
@dragcancel="stopHomeDrag"
@dragend="finishHomeDrag"
@dragmove="moveHomeDrag"
@dragstart="startHomeDrag('grid', cell.sourceIndex, $event)"
@dragstart="startHomeDrag('grid', cell.sourceIndex)"
@edit="enterEditMode"
@remove="removeHomeApp(cell.app.id)"
@reorder="reorderHomeApp('grid', cell.sourceIndex, $event)"
@@ -1556,7 +1325,6 @@ onBeforeUnmount(() => {
:data-home-index="cell.sourceIndex"
:data-home-target-offset="cell.targetOffset"
:edit-mode="editMode"
:external-drag-visual="homeDragVisualActive"
:folder="cell.folder"
:class="{
'home-item--folder-hover':
@@ -1565,7 +1333,7 @@ onBeforeUnmount(() => {
@dragcancel="stopHomeDrag"
@dragend="finishHomeDrag"
@dragmove="moveHomeDrag"
@dragstart="startHomeDrag('grid', cell.sourceIndex, $event)"
@dragstart="startHomeDrag('grid', cell.sourceIndex)"
@edit="enterEditMode"
@open="openFolder(cell.folder.id)"
@reorder="reorderHomeApp('grid', cell.sourceIndex, $event)"
@@ -1716,10 +1484,6 @@ onBeforeUnmount(() => {
</section>
</div>
<Teleport defer to="#phone-home-drag-portal">
<div ref="homeDragLayer" class="home-drag-layer" aria-hidden="true"></div>
</Teleport>
<Transition name="edit-done">
<k-glass
v-if="editMode && isEditablePage && !folderOverlayVisible"
@@ -1766,7 +1530,6 @@ onBeforeUnmount(() => {
:data-home-item-key="`app-${slot.app.id}`"
:data-home-index="slot.index"
:edit-mode="editMode"
:external-drag-visual="homeDragVisualActive"
:show-label="false"
:class="{
'home-item--folder-hover':
@@ -1775,7 +1538,7 @@ onBeforeUnmount(() => {
@dragcancel="stopHomeDrag"
@dragend="finishHomeDrag"
@dragmove="moveHomeDrag"
@dragstart="startHomeDrag('dock', slot.index, $event)"
@dragstart="startHomeDrag('dock', slot.index)"
@edit="enterEditMode"
@remove="removeHomeApp(slot.app.id)"
@reorder="reorderHomeApp('dock', slot.index, $event)"
@@ -1789,7 +1552,6 @@ onBeforeUnmount(() => {
:data-home-item-key="slot.folder.id"
:data-home-index="slot.index"
:edit-mode="editMode"
:external-drag-visual="homeDragVisualActive"
:folder="slot.folder"
:show-label="false"
:class="{
@@ -1799,7 +1561,7 @@ onBeforeUnmount(() => {
@dragcancel="stopHomeDrag"
@dragend="finishHomeDrag"
@dragmove="moveHomeDrag"
@dragstart="startHomeDrag('dock', slot.index, $event)"
@dragstart="startHomeDrag('dock', slot.index)"
@edit="enterEditMode"
@open="openFolder(slot.folder.id)"
@reorder="reorderHomeApp('dock', slot.index, $event)"
+3 -17
View File
@@ -77,10 +77,6 @@ import {
SkyToggle,
} from '@/ui'
import { nuiCall } from '@/utils/nui'
import {
getPhoneOutputVolume,
subscribePhoneOutputVolume,
} from '@/utils/phoneAudio'
type Tab = 'feed' | 'discover' | 'create' | 'activity' | 'profile'
type AuthMode = 'login' | 'register'
@@ -198,8 +194,6 @@ let flipTokYoutubePlayer: YouTubePlayer | null = null
let flipTokYoutubeApi: YouTubeApi | null = null
let flipTokYoutubeOwner = ''
let flipTokYoutubeVideoId = ''
let flipTokYoutubeVolume = 0
let removePhoneOutputVolumeListener: (() => void) | undefined
let observer: IntersectionObserver | null = null
let videoClickTimer: number | null = null
let likePulseTimer: number | null = null
@@ -792,7 +786,6 @@ async function playFlipTokYoutube(
seconds = 0,
): Promise<void> {
flipTokYoutubeOwner = owner
flipTokYoutubeVolume = volume
try {
const api = await loadYouTubeApi()
flipTokYoutubeApi = api
@@ -801,7 +794,7 @@ async function playFlipTokYoutube(
flipTokYoutubeVideoId = videoId
flipTokYoutubePlayer.loadVideoById(videoId)
}
flipTokYoutubePlayer.setVolume(volume * getPhoneOutputVolume())
flipTokYoutubePlayer.setVolume(volume)
flipTokYoutubePlayer.seekTo(Math.max(0, seconds), true)
flipTokYoutubePlayer.playVideo()
if (owner === 'composer') customMusicLoadFailed.value = false
@@ -842,7 +835,7 @@ async function playFlipTokYoutube(
},
onReady: (event) => {
flipTokYoutubePlayer = event.target
event.target.setVolume(volume * getPhoneOutputVolume())
event.target.setVolume(volume)
event.target.seekTo(Math.max(0, seconds), true)
event.target.playVideo()
if (owner === 'composer') customMusicLoadFailed.value = false
@@ -1634,10 +1627,7 @@ watch(originalVolume, (value) => {
watch(musicVolume, (value) => {
if (composerMusic.value) composerMusic.value.volume = value / 100
if (flipTokYoutubeOwner === 'composer') {
flipTokYoutubeVolume = value
flipTokYoutubePlayer?.setVolume(value * getPhoneOutputVolume())
}
if (flipTokYoutubeOwner === 'composer') flipTokYoutubePlayer?.setVolume(value)
})
watch(
@@ -1665,9 +1655,6 @@ watch(
)
onMounted(async () => {
removePhoneOutputVolumeListener = subscribePhoneOutputVolume((volume) => {
flipTokYoutubePlayer?.setVolume(flipTokYoutubeVolume * volume)
})
const profileSelection = messageMedia.consumeMany<ProfileMediaContext>(
'fliptok:profile-avatar',
)
@@ -1744,7 +1731,6 @@ onMounted(async () => {
})
onBeforeUnmount(() => {
removePhoneOutputVolumeListener?.()
observer?.disconnect()
if (videoClickTimer !== null) window.clearTimeout(videoClickTimer)
if (likePulseTimer !== null) window.clearTimeout(likePulseTimer)
@@ -1,70 +0,0 @@
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"',
)
})
})
@@ -1,303 +0,0 @@
<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,7 +229,9 @@ describe('development Sky UI Kitchen Sink contract', () => {
/const developmentRoutes[^=]*=\s*import\.meta\.env\.DEV[\s\S]*?import\('@\/views\/development\/SkyUiKitchenSinkView\.vue'\)/,
)
expect(routerSource).toContain("name: 'development-sky-ui'")
expect(appSource).toContain("route.name === 'development-sky-ui'")
expect(appSource).toContain(
"isDevelopment && route.name === 'development-sky-ui'",
)
expect(appSource).toContain(
'isDevelopmentRoute ? String(route.name) : route.path',
)
-1
View File
@@ -23,7 +23,6 @@ const lifecycleEndpoints = new Set([
'notification:focus',
'sim:picker-close',
'ui:input-focus',
'ui:live-activity',
'ui:opened',
'ui:ready',
])
-1
View File
@@ -1157,7 +1157,6 @@ async function main() {
'notification:focus',
'sim:picker-close',
'ui:input-focus',
'ui:live-activity',
'ui:opened',
'ui:ready',
]
-34
View File
@@ -6,8 +6,6 @@ local open_without_focus = false
local device_payload = nil
local equipped_phone_number = nil
local nui_generation = 0
local live_activity_active = false
local open_home_requested = false
local function get_equipped_phone_number()
if not device_payload or not device_payload.device.sim then
@@ -77,9 +75,7 @@ local function send_open_message()
SendNUIMessage({
type = "app:open",
data = payload,
openHome = open_home_requested,
})
open_home_requested = false
end
local function open_phone()
@@ -173,25 +169,6 @@ RegisterCommand("sky_phone_toggle", function()
Bridge.Callbacks.Trigger("sky_phone:device:open-request", {})
end, false)
RegisterCommand("sky_phone_live_activity_open", function()
if not live_activity_active or is_open or open_requested then
return
end
open_home_requested = true
local result = Bridge.Callbacks.Trigger("sky_phone:device:open-request", {})
if not result or not result.success then
open_home_requested = false
end
end, false)
RegisterKeyMapping(
"sky_phone_live_activity_open",
locale.Controls.OpenPhone,
"keyboard",
"SPACE"
)
if Config.Phone.Keybind then
if type(Config.Phone.Keybind) ~= "string" or Config.Phone.Keybind == "" then
error("[sky_phone] Config.Phone.Keybind must be a non-empty keyboard key name or false.")
@@ -278,15 +255,6 @@ RegisterNUICallback("ui:input-focus", function(data, cb)
cb({ success = true })
end)
RegisterNUICallback("ui:live-activity", function(data, cb)
if type(data) ~= "table" or type(data.active) ~= "boolean" then
cb({ success = false, error = "invalid_request" })
return
end
live_activity_active = data.active
cb({ success = true })
end)
RegisterNUICallback("close", function(data, cb)
if type(data) ~= "table" then
cb({ success = false, error = "invalid_request" })
@@ -334,8 +302,6 @@ RegisterNetEvent("sky_phone:device:invalidated", function()
close_phone(false)
device_payload = nil
update_equipped_phone_number(nil)
live_activity_active = false
open_home_requested = false
end)
RegisterNetEvent("sky_phone:device:error", function(error_code)
+12 -10
View File
@@ -679,16 +679,18 @@ function SkyPhoneCompanies.GetCallTargets(company_id)
end
for source, readiness in pairs(call_availability) do
local member = online[source] and call_member(source) or nil
local device = member and SkyPhone.LoadDevice(readiness.imei) or nil
local device_slots = device and SkyPhone.FindDeviceSlots(source, readiness.imei) or {}
local readiness_valid = member and device and device_slots[1]
and member.company_id == readiness.company_id
and device.sim_id == readiness.sim_id
and device.sim_type == "registered"
and device.registered_at ~= nil
if not readiness_valid then
call_availability[source] = nil
elseif readiness.company_id == company_id then
local device = member and current_device(source, true) or nil
if not member or not device or member.company_id ~= readiness.company_id
or readiness.company_id ~= company_id or readiness.imei ~= device.imei
or readiness.sim_id ~= device.sim_id
then
if not member or not device or member.company_id ~= readiness.company_id
or readiness.imei ~= (device and device.imei)
or readiness.sim_id ~= (device and device.sim_id)
then
call_availability[source] = nil
end
else
targets[#targets + 1] = {
source = source,
simId = device.sim_id,
+2 -19
View File
@@ -51,23 +51,6 @@ local function session_owner(source)
}
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 prefix = alias and ("`%s`."):format(alias) or ""
if owner.account_id then
@@ -334,7 +317,7 @@ RegisterNetEvent("sky_phone:memos:request-upload", function(data)
upload_result(src, type(data) == "table" and data.correlationId or nil, false, "invalid_memo")
return
end
local owner, error_response = device_owner(src, data.deviceImei)
local owner, error_response = session_owner(src)
if not owner then
upload_result(src, memo.correlation_id, false, error_response.error)
return
@@ -409,7 +392,7 @@ RegisterNetEvent("sky_phone:memos:complete-upload", function(data)
return
end
state.completing = true
local owner, error_response = device_owner(src, state.owner.imei)
local owner, error_response = session_owner(src)
if not owner or owner.imei ~= state.owner.imei or owner.account_id ~= state.owner.account_id then
pending_uploads[request_id] = nil
local rejected, _, trusted_remote = SkyPhoneMedia.VerifyRemoteUpload(state, data.remoteId, data.url)
+1
View File
@@ -958,6 +958,7 @@ Bridge.Debug(
)
Bridge.Callbacks.Register("sky_phone:device:close", function(source)
SkyPhoneCompanies.ClearCallAvailability(source)
sessions[source] = nil
return { success = true }
end)