diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 3ff419b..78ebe16 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -56,6 +56,7 @@ 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, @@ -70,10 +71,15 @@ 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' @@ -82,6 +88,7 @@ import { isTrustedRootMessageSource } from '@/utils/windowMessages' import SpringboardView from '@/views/SpringboardView.vue' type AppMessage = { + openHome?: boolean type?: string data?: | CalendarReminderData @@ -313,6 +320,7 @@ const notes = useNotesStore() const memos = useMemosStore() const weather = useWeatherStore() const easyShare = useEasyShareStore() +const radio = useRadioStore() const notifications = useNotificationsStore() const route = useRoute() const router = useRouter() @@ -335,8 +343,13 @@ 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', + () => + isDevelopment && + (route.name === 'development-sky-ui' || isDynamicIslandGalleryRoute.value), ) const appTransitionName = computed(() => route.query.transition === 'app-switch' ? 'app-switch' : 'app-window', @@ -356,9 +369,12 @@ const setupPreviewDismissed = ref(false) const setupDevelopmentSkipped = ref(false) const setupAppearanceSelected = ref(false) const pendingUnlockRoute = ref(null) +const openHomeRequested = ref(false) const unlockedServicesLoaded = ref(false) const controlCenterOpened = ref(false) const activitySuspended = ref(false) +const dynamicIslandExpanded = ref(false) +const dynamicIslandActivity = ref(null) const simPicker = ref(null) const setupRequired = computed( () => @@ -432,6 +448,12 @@ const phoneResolutionStyle = computed(() => ({ })) const phoneStageStyle = computed(() => ({ ...phoneResolutionStyle.value, + '--phone-live-activity-peek-height': + dynamicIslandActivity.value === 'music' + ? '190px' + : dynamicIslandActivity.value === 'recording' + ? '132px' + : '112px', visibility: activitySuspended.value ? 'hidden' : 'visible', })) const phoneDisplayStyle = computed(() => ({ @@ -449,6 +471,7 @@ 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 @@ -758,6 +781,7 @@ function onMessage(event: MessageEvent): 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') { @@ -1473,6 +1497,7 @@ function onFocusOut(event: FocusEvent): void { } onMounted(() => { + removePhoneAudioController = installPhoneAudioController() document.addEventListener('focusin', onFocusIn) document.addEventListener('focusout', onFocusOut) window.addEventListener('message', onMessage) @@ -1587,6 +1612,23 @@ watch( }, ) +watch( + () => Boolean(dynamicIslandActivity.value || calls.activeCall), + (active) => { + void nuiCall('ui:live-activity', { active }) + }, + { immediate: true }, +) + +watch( + hardwareAlertVolume, + (volume) => { + setPhoneOutputVolume(volume / 100) + if (radio.data.connected) void radio.setVolume(volume) + }, + { immediate: true }, +) + watch( () => phone.isOpen, (isOpen) => { @@ -1619,7 +1661,8 @@ watch( } isLocked.value = setupRequired.value ? false - : !isDevelopment || developmentLockScreenPreview + : developmentLockScreenPreview || + (!isDevelopment && phone.security.enabled) passcodeRequired.value = isLocked.value && phone.security.enabled unlockedServicesLoaded.value = false controlCenterOpened.value = false @@ -1637,8 +1680,18 @@ watch( startPasscodeLock(passcodeRetrySeconds.value) } phone.setLaunchOrigin(null) - if (isLocked.value || setupRequired.value) void router.replace('/') - else loadUnlockedPhoneData() + 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() + } }, ) @@ -1650,6 +1703,7 @@ watch( ) onBeforeUnmount(() => { + removePhoneAudioController?.() updateTextInputFocus(false) cancelUnlockedPhoneDataLoad() weather.stop() @@ -1688,12 +1742,15 @@ 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" @@ -1713,7 +1770,12 @@ onBeforeUnmount(() => { @open="openNotificationPreview" />
@@ -1721,6 +1783,7 @@ onBeforeUnmount(() => { 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')" @@ -1832,7 +1895,6 @@ onBeforeUnmount(() => { @control-center="toggleControlCenter" @lock="lockPhone" /> - { aria-hidden="true" draggable="false" /> +
diff --git a/frontend/src/AppDevelopmentPreview.contract.test.ts b/frontend/src/AppDevelopmentPreview.contract.test.ts index da85cf8..bd46b82 100644 --- a/frontend/src/AppDevelopmentPreview.contract.test.ts +++ b/frontend/src/AppDevelopmentPreview.contract.test.ts @@ -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') +const source = 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', @@ -22,15 +22,32 @@ describe('browser development preview contract', () => { it('starts unlocked while preserving an explicit lock screen preview', () => { expect(source).toContain("developmentParameters.has('lockScreenPreview')") - expect(source).toContain(': !isDevelopment || developmentLockScreenPreview') + expect(source).toContain( + 'developmentLockScreenPreview ||\n (!isDevelopment && phone.security.enabled)', + ) expect(source).toContain("developmentParameters.has('setupPreview')") }) - it('loads authenticated app data without replacing direct app routes', () => { - expect(source).toContain( + 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( "if (isLocked.value || setupRequired.value) void router.replace('/')", ) - expect(source).toContain('else loadUnlockedPhoneData()') + }) + + it('opens Space-triggered live activities on Home or the enabled lock screen', () => { + expect(source).toContain( + 'openHomeRequested.value = event.data.openHome === true', + ) + expect(source).toContain( + "if (isLocked.value) pendingUnlockRoute.value = '/'", + ) + expect(source).toContain("void router.replace('/')") }) it('requires the passcode again after a full device lock', () => { @@ -100,7 +117,9 @@ 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, ) diff --git a/frontend/src/assets/main.css b/frontend/src/assets/main.css index 41ea588..1eb36f7 100644 --- a/frontend/src/assets/main.css +++ b/frontend/src/assets/main.css @@ -259,6 +259,20 @@ 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; } @@ -384,7 +398,7 @@ button { overflow: hidden; border: 1px solid rgb(255 255 255 / 16%); border-radius: 15px; - color: #0a84ff; + color: #a9abb2; background: rgb(47 47 51 / 98%); box-shadow: 0 8px 24px rgb(0 0 0 / 28%); pointer-events: none; @@ -672,6 +686,9 @@ 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; @@ -795,78 +812,6 @@ 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; diff --git a/frontend/src/companiesServiceLineCalls.contract.test.ts b/frontend/src/companiesServiceLineCalls.contract.test.ts index b604bcc..c110c9f 100644 --- a/frontend/src/companiesServiceLineCalls.contract.test.ts +++ b/frontend/src/companiesServiceLineCalls.contract.test.ts @@ -14,6 +14,10 @@ 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', @@ -85,3 +89,35 @@ 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)') + }) +}) diff --git a/frontend/src/components/PayphoneOverlay.vue b/frontend/src/components/PayphoneOverlay.vue index eb1fbd3..fef7d10 100644 --- a/frontend/src/components/PayphoneOverlay.vue +++ b/frontend/src/components/PayphoneOverlay.vue @@ -3,6 +3,7 @@ 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 = @@ -70,7 +71,9 @@ const buttonSounds: HTMLAudioElement[] = [] function prepareButtonSounds(): void { if (buttonSounds.length) return for (let index = 0; index < 4; index += 1) { - const sound = new Audio(`${import.meta.env.BASE_URL}sounds/button.mp3`) + const sound = registerPhoneMediaElement( + new Audio(`${import.meta.env.BASE_URL}sounds/button.mp3`), + ) sound.preload = 'auto' sound.volume = 0.55 buttonSounds.push(sound) diff --git a/frontend/src/components/PhoneDynamicIsland.contract.test.ts b/frontend/src/components/PhoneDynamicIsland.contract.test.ts index 7997f79..627f510 100644 --- a/frontend/src/components/PhoneDynamicIsland.contract.test.ts +++ b/frontend/src/components/PhoneDynamicIsland.contract.test.ts @@ -5,8 +5,15 @@ 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', @@ -19,16 +26,23 @@ 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('') expect(appSource).toContain( - 'phone.isOpen || notifications.current || calls.activeCall', + "'phone-device--island-expanded': dynamicIslandExpanded", ) + expect(appSource).toMatch( + /class="phone-device__frame"[\s\S]*? void router.push('/apps/phone'), 0)", ) @@ -45,7 +59,44 @@ 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()"') @@ -53,9 +104,31 @@ 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('keeps recorder state available across app changes', () => { + it('matches the reference music player and timer control layouts', () => { + expect(source).toContain('phone-dynamic-island__music-equalizer') + expect(source).toContain('phone-dynamic-island__progress-track') + expect(source).toContain('{{ musicElapsedLabel }}') + expect(source).toContain('{{ musicRemainingLabel }}') + expect(source).not.toContain('Airplay') + expect(source).toContain('