ADD - add iOS-style control center

This commit is contained in:
Eichenholz
2026-08-08 09:08:18 +02:00
parent 0ca6e15da2
commit ca778d1eb7
12 changed files with 1182 additions and 13 deletions
+49 -4
View File
@@ -11,6 +11,7 @@ import {
import { useRoute, useRouter } from 'vue-router'
import PhoneHomeIndicator from '@/components/PhoneHomeIndicator.vue'
import PhoneControlCenter from '@/components/PhoneControlCenter.vue'
import PhoneMediaCapture from '@/components/PhoneMediaCapture.vue'
import PhoneLockScreen from '@/components/PhoneLockScreen.vue'
import PhoneNotifications from '@/components/PhoneNotifications.vue'
@@ -139,6 +140,7 @@ const appTransitionName = computed(() =>
)
const isLocked = ref(false)
const isUnlocking = ref(false)
const controlCenterOpened = ref(false)
const simPicker = ref<SimPickerPayload | null>(null)
const systemColorScheme = window.matchMedia('(prefers-color-scheme: dark)')
const viewportScale = ref(getViewportScale())
@@ -149,6 +151,11 @@ const phoneResolutionStyle = computed<CSSProperties>(() => ({
'--phone-zoom':
phoneBaseZoom.value * (phone.preferences.settings.phoneScale / 100),
}))
const phoneDisplayStyle = computed<CSSProperties>(() => ({
'--phone-display-dim': String(
((100 - phone.preferences.settings.screenBrightness) / 100) * 0.8,
),
}))
const phoneFrameImage = computed(
() => PHONE_FRAME_IMAGES[phone.preferences.settings.frame],
)
@@ -338,17 +345,26 @@ function onMessage(event: MessageEvent<AppMessage>): void {
} else if (event.data?.type === 'darkchat:new' && event.data.data) {
const data = event.data.data as DarkChatEventData
void darkchat.refreshInbox()
if (data.conversationId && darkchat.activeConversation?.id === data.conversationId) {
if (
data.conversationId &&
darkchat.activeConversation?.id === data.conversationId
) {
void darkchat.openThread(data.conversationId)
}
if (data.notificationMode !== 'hidden') {
const notification: PhoneNotificationInput = {
appId: 'darkchat',
subtitle: data.sender,
text: data.text ?? data.preview ?? phone.t('Apps.darkchat.privateNotification'),
text:
data.text ??
data.preview ??
phone.t('Apps.darkchat.privateNotification'),
title: data.title ?? phone.t('Apps.darkchat.name'),
}
if (data.device && (!phone.isOpen || data.device.imei !== phone.device?.imei)) {
if (
data.device &&
(!phone.isOpen || data.device.imei !== phone.device?.imei)
) {
notification.device = {
imei: data.device.imei,
name: data.device.name,
@@ -367,6 +383,7 @@ function onMessage(event: MessageEvent<AppMessage>): void {
event.data.data
) {
calls.applyCallState(event.data.data as PhoneCall)
controlCenterOpened.value = false
isLocked.value = false
isUnlocking.value = false
window.setTimeout(() => void router.push('/apps/phone'), 0)
@@ -379,6 +396,10 @@ function onMessage(event: MessageEvent<AppMessage>): void {
function onKeydown(event: KeyboardEvent): void {
if (event.key !== 'Escape' || !phone.isOpen) return
if (controlCenterOpened.value) {
controlCenterOpened.value = false
return
}
phone.close()
void nuiCall('close')
}
@@ -401,6 +422,11 @@ function unlockPhone(): void {
}, 720)
}
function toggleControlCenter(): void {
if (isLocked.value) return
controlCenterOpened.value = !controlCenterOpened.value
}
function unlockCamera(): void {
unlockPhone()
window.setTimeout(() => void router.push('/apps/camera'), 0)
@@ -487,11 +513,13 @@ watch(
if (unlockTimer !== undefined) window.clearTimeout(unlockTimer)
if (!isOpen) {
weather.stop()
controlCenterOpened.value = false
isLocked.value = false
isUnlocking.value = false
return
}
isLocked.value = true
controlCenterOpened.value = false
weather.start()
isUnlocking.value = false
phone.setLaunchOrigin(null)
@@ -499,6 +527,13 @@ watch(
},
)
watch(
() => phone.cameraLandscape,
(landscape) => {
if (landscape) controlCenterOpened.value = false
},
)
onBeforeUnmount(() => {
weather.stop()
if (clockTicker) clearInterval(clockTicker)
@@ -560,13 +595,18 @@ onBeforeUnmount(() => {
:dark="phone.isDarkMode"
safe-areas
class="phone-app"
:style="phoneDisplayStyle"
:class="{
dark: phone.isDarkMode,
'phone-app--light': !phone.isDarkMode,
'phone-app--unlocking': isUnlocking,
}"
>
<PhoneStatusBar v-if="!isLocked" />
<PhoneStatusBar
v-if="!isLocked"
:control-center-opened="controlCenterOpened"
@control-center="toggleControlCenter"
/>
<SpringboardView />
<RouterView v-slot="{ Component }">
<Transition :name="appTransitionName">
@@ -578,6 +618,10 @@ onBeforeUnmount(() => {
</Transition>
</RouterView>
<PhoneHomeIndicator v-if="!isLocked" />
<PhoneControlCenter
:opened="controlCenterOpened"
@close="controlCenterOpened = false"
/>
<Transition name="lock-screen">
<PhoneLockScreen
v-if="isLocked"
@@ -589,6 +633,7 @@ onBeforeUnmount(() => {
:notification="notifications.current"
@close="notifications.dismissCurrent()"
/>
<div class="phone-display-dimmer" aria-hidden="true"></div>
</k-app>
</div>
<img
+34 -1
View File
@@ -324,6 +324,27 @@ button {
.phone-app--light {
color-scheme: light;
}
.phone-display-dimmer {
position: absolute;
z-index: 99;
inset: 0;
background: #000;
opacity: var(--phone-display-dim, 0);
pointer-events: none;
transition: opacity 120ms linear;
}
.control-center__range input[type='range']::-webkit-slider-thumb {
width: 1px !important;
height: 1px !important;
opacity: 0 !important;
-webkit-appearance: none !important;
}
.control-center__range input[type='range']::-moz-range-thumb {
width: 1px !important;
height: 1px !important;
border: 0 !important;
opacity: 0 !important;
}
.phone-notification {
z-index: 85 !important;
top: 68px !important;
@@ -339,7 +360,7 @@ button {
}
.phone-status-bar {
position: absolute;
z-index: 70;
z-index: 97;
top: 0;
left: 0;
width: 100%;
@@ -355,9 +376,21 @@ button {
pointer-events: none;
}
.phone-status-bar__indicators {
position: absolute;
top: 0;
right: 0;
display: flex;
align-items: center;
justify-content: flex-end;
gap: 3px;
width: 118px;
height: 45px;
padding: 11px 42px 0 0;
border: 0;
color: inherit;
background: transparent;
cursor: pointer;
pointer-events: auto;
}
.phone-home-indicator {
position: absolute;
@@ -0,0 +1,927 @@
<script setup lang="ts">
import { kGlass, kRange } from 'konsta/vue'
import {
BellOff,
Bluetooth,
Calculator,
Camera,
Flashlight,
LockKeyhole,
Moon,
Plane,
Play,
RotateCw,
Signal,
SkipBack,
SkipForward,
Sun,
TimerReset,
Volume2,
Wifi,
} from 'lucide-vue-next'
import {
computed,
nextTick,
onBeforeUnmount,
ref,
watch,
type CSSProperties,
} from 'vue'
import { useRouter } from 'vue-router'
import { usePhoneStore } from '@/stores/phone'
import { nuiCall } from '@/utils/nui'
const props = defineProps<{ opened: boolean }>()
const emit = defineEmits<{ close: [] }>()
type ConnectivityPreference =
| 'airplaneMode'
| 'bluetoothEnabled'
| 'cellularEnabled'
| 'focusMode'
| 'rotationLocked'
| 'wifiEnabled'
const phone = usePhoneStore()
const router = useRouter()
const panel = ref<HTMLElement | null>(null)
const brightness = ref(phone.preferences.settings.screenBrightness)
const volume = ref(
Math.round(
(phone.preferences.settings.notificationVolume +
phone.preferences.settings.ringtoneVolume) /
2,
),
)
const flashlightActive = ref(false)
const flashlightPending = ref(false)
const previousAlertVolume = ref(volume.value || 75)
const inactiveGlassColors = {
bgIos: 'bg-[rgba(72,72,74,0.58)]',
shadowIos: 'shadow-ios-dark-glass',
}
const rotationColors = computed(() =>
phone.preferences.settings.rotationLocked
? {
bgIos: 'bg-white',
shadowIos: 'shadow-ios-light-glass',
}
: inactiveGlassColors,
)
const focusColors = computed(() =>
phone.preferences.settings.focusMode
? {
bgIos: 'bg-[#5e5ce6]',
shadowIos: 'shadow-ios-dark-glass',
}
: inactiveGlassColors,
)
const alertsMuted = computed(() => volume.value === 0)
const alertMuteColors = computed(() =>
alertsMuted.value
? {
bgIos: 'bg-white',
shadowIos: 'shadow-ios-light-glass',
}
: inactiveGlassColors,
)
const flashlightColors = computed(() =>
flashlightActive.value
? {
bgIos: 'bg-white',
shadowIos: 'shadow-ios-light-glass',
}
: inactiveGlassColors,
)
const brightnessStyle = computed<CSSProperties>(() => ({
'--control-level': `${brightness.value}%`,
}))
const volumeStyle = computed<CSSProperties>(() => ({
'--control-level': `${volume.value}%`,
}))
function togglePreference(key: ConnectivityPreference): void {
phone.setPreference(key, !phone.preferences.settings[key])
}
function updateBrightness(event: Event): void {
const nextValue = Number.parseInt(
(event.target as HTMLInputElement).value,
10,
)
brightness.value = nextValue
phone.preferences.settings.screenBrightness = nextValue
}
function saveBrightness(event: Event): void {
phone.setPreference(
'screenBrightness',
Number.parseInt((event.target as HTMLInputElement).value, 10),
)
}
function updateVolume(event: Event): void {
volume.value = Number.parseInt((event.target as HTMLInputElement).value, 10)
}
function saveVolume(event: Event): void {
const nextValue = Number.parseInt(
(event.target as HTMLInputElement).value,
10,
)
if (nextValue > 0) previousAlertVolume.value = nextValue
phone.setAlertVolumes(nextValue)
}
function toggleAlertMute(): void {
if (alertsMuted.value) {
volume.value = previousAlertVolume.value
} else {
previousAlertVolume.value = volume.value
volume.value = 0
}
phone.setAlertVolumes(volume.value)
}
function pointerRangeValue(
event: PointerEvent,
minimum: number,
maximum: number,
): number {
const rect = (event.currentTarget as HTMLElement).getBoundingClientRect()
const ratio =
1 - Math.min(1, Math.max(0, (event.clientY - rect.top) / rect.height))
return Math.round(minimum + ratio * (maximum - minimum))
}
function startBrightnessDrag(event: PointerEvent): void {
;(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId)
const nextValue = pointerRangeValue(event, 10, 100)
brightness.value = nextValue
phone.preferences.settings.screenBrightness = nextValue
}
function dragBrightness(event: PointerEvent): void {
if (!(event.currentTarget as HTMLElement).hasPointerCapture(event.pointerId))
return
const nextValue = pointerRangeValue(event, 10, 100)
brightness.value = nextValue
phone.preferences.settings.screenBrightness = nextValue
}
function finishBrightnessDrag(event: PointerEvent): void {
const target = event.currentTarget as HTMLElement
if (!target.hasPointerCapture(event.pointerId)) return
const nextValue = pointerRangeValue(event, 10, 100)
brightness.value = nextValue
phone.setPreference('screenBrightness', nextValue)
target.releasePointerCapture(event.pointerId)
}
function startVolumeDrag(event: PointerEvent): void {
;(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId)
volume.value = pointerRangeValue(event, 0, 100)
}
function dragVolume(event: PointerEvent): void {
if (!(event.currentTarget as HTMLElement).hasPointerCapture(event.pointerId))
return
volume.value = pointerRangeValue(event, 0, 100)
}
function finishVolumeDrag(event: PointerEvent): void {
const target = event.currentTarget as HTMLElement
if (!target.hasPointerCapture(event.pointerId)) return
volume.value = pointerRangeValue(event, 0, 100)
if (volume.value > 0) previousAlertVolume.value = volume.value
phone.setAlertVolumes(volume.value)
target.releasePointerCapture(event.pointerId)
}
async function toggleFlashlight(): Promise<void> {
if (flashlightPending.value) return
const enabled = !flashlightActive.value
flashlightActive.value = enabled
flashlightPending.value = true
const response = await nuiCall('camera:setFlash', { enabled })
if (!response.success) flashlightActive.value = !enabled
flashlightPending.value = false
}
function openApp(path: string): void {
emit('close')
void router.push(path)
}
function openTimer(): void {
emit('close')
void router.push({ path: '/apps/clock', query: { section: 'timer' } })
}
watch(
() => props.opened,
(opened) => {
if (!opened) return
brightness.value = phone.preferences.settings.screenBrightness
volume.value = Math.round(
(phone.preferences.settings.notificationVolume +
phone.preferences.settings.ringtoneVolume) /
2,
)
if (volume.value > 0) previousAlertVolume.value = volume.value
void nextTick(() => panel.value?.focus({ preventScroll: true }))
},
)
onBeforeUnmount(() => {
if (flashlightActive.value)
void nuiCall('camera:setFlash', { enabled: false })
})
</script>
<template>
<Transition name="control-center">
<section
v-if="opened"
class="control-center"
role="dialog"
aria-modal="true"
:aria-label="phone.t('ControlCenter.label')"
>
<button
class="control-center__backdrop"
type="button"
tabindex="-1"
:aria-label="phone.t('ControlCenter.close')"
@click="emit('close')"
></button>
<div ref="panel" class="control-center__panel" tabindex="-1">
<div class="control-center__top-grid">
<k-glass
class="control-center__connectivity"
:colors="inactiveGlassColors"
:highlight="false"
>
<button
type="button"
class="control-center__connectivity-button"
:class="{
'control-center__connectivity-button--airplane':
phone.preferences.settings.airplaneMode,
}"
:aria-label="phone.t('ControlCenter.airplaneMode')"
:aria-pressed="phone.preferences.settings.airplaneMode"
@click="togglePreference('airplaneMode')"
>
<Plane aria-hidden="true" />
</button>
<button
type="button"
class="control-center__connectivity-button"
:class="{
'control-center__connectivity-button--cellular':
phone.preferences.settings.cellularEnabled,
}"
:aria-label="phone.t('ControlCenter.cellular')"
:aria-pressed="phone.preferences.settings.cellularEnabled"
@click="togglePreference('cellularEnabled')"
>
<Signal aria-hidden="true" />
</button>
<button
type="button"
class="control-center__connectivity-button"
:class="{
'control-center__connectivity-button--wifi':
phone.preferences.settings.wifiEnabled,
}"
:aria-label="phone.t('ControlCenter.wifi')"
:aria-pressed="phone.preferences.settings.wifiEnabled"
@click="togglePreference('wifiEnabled')"
>
<Wifi aria-hidden="true" />
</button>
<button
type="button"
class="control-center__connectivity-button"
:class="{
'control-center__connectivity-button--bluetooth':
phone.preferences.settings.bluetoothEnabled,
}"
:aria-label="phone.t('ControlCenter.bluetooth')"
:aria-pressed="phone.preferences.settings.bluetoothEnabled"
@click="togglePreference('bluetoothEnabled')"
>
<Bluetooth aria-hidden="true" />
</button>
</k-glass>
<k-glass
class="control-center__media"
:colors="inactiveGlassColors"
:highlight="false"
>
<div class="control-center__media-copy">
<span>{{ phone.t('ControlCenter.media') }}</span>
<strong>{{ phone.t('ControlCenter.notPlaying') }}</strong>
</div>
<div class="control-center__media-controls">
<button
type="button"
disabled
:aria-label="phone.t('ControlCenter.previous')"
>
<SkipBack aria-hidden="true" />
</button>
<button
type="button"
disabled
:aria-label="phone.t('ControlCenter.play')"
>
<Play aria-hidden="true" />
</button>
<button
type="button"
disabled
:aria-label="phone.t('ControlCenter.next')"
>
<SkipForward aria-hidden="true" />
</button>
</div>
</k-glass>
</div>
<div class="control-center__middle-grid">
<div class="control-center__round-control">
<k-glass
component="button"
type="button"
class="control-center__round-button"
:class="{
'control-center__round-button--light':
phone.preferences.settings.rotationLocked,
}"
:colors="rotationColors"
:aria-label="phone.t('ControlCenter.rotationLock')"
:aria-pressed="phone.preferences.settings.rotationLocked"
@click="togglePreference('rotationLocked')"
>
<RotateCw aria-hidden="true" />
<LockKeyhole
class="control-center__rotation-lock"
aria-hidden="true"
/>
</k-glass>
</div>
<div class="control-center__round-control">
<k-glass
component="button"
type="button"
class="control-center__round-button"
:class="{
'control-center__round-button--light': alertsMuted,
}"
:colors="alertMuteColors"
:aria-label="
phone.t(
alertsMuted
? 'ControlCenter.unmuteRingtone'
: 'ControlCenter.muteRingtone',
)
"
:aria-pressed="alertsMuted"
@click="toggleAlertMute"
>
<BellOff aria-hidden="true" />
</k-glass>
</div>
<k-glass
class="control-center__slider"
:colors="inactiveGlassColors"
:highlight="false"
:style="brightnessStyle"
@pointerdown="startBrightnessDrag"
@pointermove="dragBrightness"
@pointerup="finishBrightnessDrag"
@pointercancel="finishBrightnessDrag"
>
<span
class="control-center__slider-level"
aria-hidden="true"
></span>
<k-range
class="control-center__range"
:value="brightness"
:min="10"
:max="100"
:aria-label="phone.t('ControlCenter.brightness')"
@input="updateBrightness"
@change="saveBrightness"
/>
<Sun
class="control-center__slider-icon"
fill="currentColor"
aria-hidden="true"
/>
</k-glass>
<k-glass
class="control-center__slider"
:colors="inactiveGlassColors"
:highlight="false"
:style="volumeStyle"
@pointerdown="startVolumeDrag"
@pointermove="dragVolume"
@pointerup="finishVolumeDrag"
@pointercancel="finishVolumeDrag"
>
<span
class="control-center__slider-level"
aria-hidden="true"
></span>
<k-range
class="control-center__range"
:value="volume"
:min="0"
:max="100"
:aria-label="phone.t('ControlCenter.volume')"
@input="updateVolume"
@change="saveVolume"
/>
<Volume2
class="control-center__slider-icon"
fill="currentColor"
aria-hidden="true"
/>
</k-glass>
<k-glass
component="button"
type="button"
class="control-center__focus-button"
:colors="focusColors"
:aria-label="phone.t('ControlCenter.focus')"
:aria-pressed="phone.preferences.settings.focusMode"
@click="togglePreference('focusMode')"
>
<Moon aria-hidden="true" />
<span>{{ phone.t('ControlCenter.focus') }}</span>
</k-glass>
</div>
<nav
class="control-center__quick-actions"
:aria-label="phone.t('ControlCenter.quickActions')"
>
<div class="control-center__quick-action">
<k-glass
component="button"
type="button"
class="control-center__quick-button"
:class="{
'control-center__quick-button--flashlight-active':
flashlightActive,
}"
:colors="flashlightColors"
:disabled="flashlightPending"
:aria-label="phone.t('ControlCenter.flashlight')"
:aria-pressed="flashlightActive"
@click="toggleFlashlight"
>
<Flashlight aria-hidden="true" />
</k-glass>
<span>{{ phone.t('ControlCenter.flashlight') }}</span>
</div>
<div class="control-center__quick-action">
<k-glass
component="button"
type="button"
class="control-center__quick-button"
:colors="inactiveGlassColors"
:aria-label="phone.t('ControlCenter.timer')"
@click="openTimer"
>
<TimerReset aria-hidden="true" />
</k-glass>
<span>{{ phone.t('ControlCenter.timer') }}</span>
</div>
<div class="control-center__quick-action">
<k-glass
component="button"
type="button"
class="control-center__quick-button"
:colors="inactiveGlassColors"
:aria-label="phone.t('ControlCenter.camera')"
@click="openApp('/apps/camera')"
>
<Camera aria-hidden="true" />
</k-glass>
<span>{{ phone.t('ControlCenter.camera') }}</span>
</div>
<div class="control-center__quick-action">
<k-glass
component="button"
type="button"
class="control-center__quick-button"
:colors="inactiveGlassColors"
:aria-label="phone.t('ControlCenter.calculator')"
@click="openApp('/apps/calculator')"
>
<Calculator aria-hidden="true" />
</k-glass>
<span>{{ phone.t('ControlCenter.calculator') }}</span>
</div>
</nav>
</div>
</section>
</Transition>
</template>
<style scoped>
.control-center {
position: absolute;
z-index: 95;
inset: 0;
overflow: hidden;
color: #fff;
isolation: isolate;
}
.control-center__backdrop {
position: absolute;
z-index: -1;
inset: 0;
width: 100%;
height: 100%;
border: 0;
background: rgba(20, 20, 24, 0.34);
backdrop-filter: blur(25px) saturate(1.45);
-webkit-backdrop-filter: blur(25px) saturate(1.45);
}
.control-center__panel {
position: relative;
display: flex;
flex-direction: column;
gap: 12px;
width: 100%;
height: 100%;
padding: 62px 18px 30px;
outline: none;
pointer-events: none;
transform-origin: calc(100% - 38px) 18px;
}
.control-center__top-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
pointer-events: auto;
}
.control-center__connectivity,
.control-center__media {
min-width: 0;
height: 154px;
border-radius: 34px;
}
.control-center__connectivity {
display: grid;
grid-template-columns: repeat(2, 1fr);
grid-template-rows: repeat(2, 1fr);
gap: 10px;
padding: 12px;
}
.control-center__connectivity-button {
display: flex;
align-items: center;
justify-content: center;
min-width: 0;
border: 0;
border-radius: 50%;
color: #fff;
background: rgba(72, 72, 74, 0.64);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.14),
0 2px 5px rgba(0, 0, 0, 0.28);
transition:
background 180ms ease,
box-shadow 180ms ease,
transform 160ms ease;
}
.control-center__connectivity-button--airplane {
background: #ff9f0a;
}
.control-center__connectivity-button--cellular {
background: #34c759;
}
.control-center__connectivity-button--wifi,
.control-center__connectivity-button--bluetooth {
background: #0a84ff;
}
.control-center__connectivity-button:active,
.control-center__round-button:active,
.control-center__quick-button:active {
transform: scale(0.92);
}
.control-center__connectivity-button svg {
width: 27px;
height: 27px;
stroke-width: 2.3;
}
.control-center__connectivity-button:first-child svg {
fill: currentcolor;
stroke-width: 1.5;
}
.control-center__media {
display: flex;
flex-direction: column;
justify-content: space-between;
padding: 21px 18px 17px;
}
.control-center__media-copy {
display: flex;
flex-direction: column;
gap: 3px;
min-width: 0;
}
.control-center__media-copy span {
color: rgba(255, 255, 255, 0.66);
font-size: 11px;
font-weight: 600;
}
.control-center__media-copy strong {
overflow: hidden;
font-size: 17px;
line-height: 1.2;
text-overflow: ellipsis;
white-space: nowrap;
}
.control-center__media-controls {
display: flex;
align-items: center;
justify-content: space-between;
}
.control-center__media-controls button {
display: flex;
align-items: center;
justify-content: center;
width: 38px;
height: 38px;
padding: 0;
border: 0;
color: rgba(255, 255, 255, 0.88);
background: transparent;
}
.control-center__media-controls button:nth-child(2) svg {
width: 31px;
height: 31px;
fill: currentcolor;
}
.control-center__media-controls svg {
width: 25px;
height: 25px;
fill: currentcolor;
}
.control-center__middle-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
grid-template-rows: repeat(2, 73px);
gap: 10px;
min-height: 156px;
pointer-events: auto;
}
.control-center__round-control,
.control-center__quick-action {
display: flex;
min-width: 0;
flex-direction: column;
align-items: center;
gap: 7px;
}
.control-center__round-control {
justify-content: center;
}
.control-center__round-control > span,
.control-center__quick-action > span {
width: 100%;
overflow: hidden;
color: rgba(255, 255, 255, 0.82);
font-size: 10px;
font-weight: 600;
line-height: 1.15;
text-align: center;
text-overflow: ellipsis;
white-space: nowrap;
}
.control-center__round-button {
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: 73px;
height: 73px;
border: 0;
border-radius: 50%;
color: #fff;
transition: transform 160ms ease;
}
.control-center__round-button--light {
color: #ff453a;
}
.control-center__focus-button {
display: flex;
grid-column: 1 / span 2;
grid-row: 2;
align-items: center;
gap: 13px;
min-width: 0;
height: 73px;
padding: 0 22px;
border: 0;
border-radius: 37px;
color: #fff;
font-size: 15px;
font-weight: 650;
transition: transform 160ms ease;
}
.control-center__focus-button:active {
transform: scale(0.96);
}
.control-center__focus-button svg {
width: 27px;
height: 27px;
fill: currentcolor;
}
.control-center__round-button svg,
.control-center__quick-button svg {
width: 27px;
height: 27px;
stroke-width: 2;
}
.control-center__round-button .control-center__rotation-lock {
position: absolute;
width: 13px;
height: 13px;
stroke-width: 2.5;
}
.control-center__slider {
position: relative;
grid-row: 1 / span 2;
height: 156px;
overflow: hidden;
border-radius: 38px;
cursor: pointer;
touch-action: none;
}
.control-center__slider-level {
position: absolute;
z-index: 0;
right: 0;
bottom: 0;
left: 0;
height: var(--control-level);
background: rgba(255, 255, 255, 0.96);
transition: height 80ms linear;
}
.control-center__range {
position: absolute;
z-index: 2;
top: 50%;
left: 50%;
width: 172px;
height: 58px !important;
transform: translate(-50%, -50%) rotate(-90deg);
pointer-events: none;
}
.control-center__range :deep(span) {
opacity: 0;
}
.control-center__range :deep(input) {
height: 58px !important;
}
.control-center__range :deep(input::-webkit-slider-thumb) {
opacity: 0;
}
.control-center__range :deep(input::-moz-range-thumb) {
opacity: 0;
}
.control-center__slider-icon {
position: absolute;
z-index: 3;
bottom: 18px;
left: 50%;
width: 25px;
height: 25px;
color: #0a84ff;
transform: translateX(-50%);
pointer-events: none;
}
.control-center__quick-button.control-center__quick-button--flashlight-active {
color: #bf5af2 !important;
}
.control-center__slider:nth-child(3) .control-center__slider-icon {
color: #ffd60a;
}
.control-center__quick-actions {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 10px;
pointer-events: auto;
}
.control-center__quick-button {
display: flex;
align-items: center;
justify-content: center;
width: 73px;
height: 73px;
border: 0;
border-radius: 50%;
color: #fff;
transition: transform 160ms ease;
}
.control-center__quick-button:disabled {
opacity: 0.72;
}
.control-center-enter-active,
.control-center-leave-active {
transition: opacity 280ms ease;
}
.control-center-enter-active .control-center__panel {
transition: transform 440ms cubic-bezier(0.16, 1, 0.3, 1);
}
.control-center-leave-active .control-center__panel {
transition: transform 260ms cubic-bezier(0.7, 0, 0.84, 0);
}
.control-center-enter-from,
.control-center-leave-to {
opacity: 0;
}
.control-center-enter-from .control-center__panel,
.control-center-leave-to .control-center__panel {
transform: translate3d(28px, -22px, 0) scale(0.84);
}
@media (prefers-reduced-motion: reduce) {
.control-center-enter-active,
.control-center-leave-active,
.control-center-enter-active .control-center__panel,
.control-center-leave-active .control-center__panel,
.control-center__connectivity-button,
.control-center__round-button,
.control-center__focus-button,
.control-center__quick-button {
transition-duration: 1ms;
}
}
</style>
+34 -5
View File
@@ -1,9 +1,13 @@
<script setup lang="ts">
import { BatteryMedium, Signal, Wifi } from 'lucide-vue-next'
import { BatteryMedium, Plane, Signal, Wifi } from 'lucide-vue-next'
import { onBeforeUnmount, onMounted, ref } from 'vue'
import { usePhoneStore } from '@/stores/phone'
const phone = usePhoneStore()
withDefaults(defineProps<{ controlCenterOpened?: boolean }>(), {
controlCenterOpened: false,
})
const emit = defineEmits<{ controlCenter: [] }>()
const time = ref('')
let intervalId: number | undefined
@@ -27,10 +31,35 @@ onBeforeUnmount(() => {
<template>
<header class="phone-status-bar" :aria-label="phone.t('Common.phoneStatus')">
<time>{{ time }}</time>
<div class="phone-status-bar__indicators" aria-hidden="true">
<Signal :size="12" :stroke-width="2.5" />
<Wifi :size="13" :stroke-width="2.5" />
<button
class="phone-status-bar__indicators"
type="button"
:aria-label="phone.t('ControlCenter.open')"
:aria-expanded="controlCenterOpened"
@click.stop="emit('controlCenter')"
>
<Plane
v-if="phone.preferences.settings.airplaneMode"
:size="12"
:stroke-width="2.5"
aria-hidden="true"
/>
<Signal
v-else-if="phone.preferences.settings.cellularEnabled"
:size="12"
:stroke-width="2.5"
aria-hidden="true"
/>
<Wifi
v-if="
phone.preferences.settings.wifiEnabled &&
!phone.preferences.settings.airplaneMode
"
:size="13"
:stroke-width="2.5"
aria-hidden="true"
/>
<BatteryMedium :size="16" :stroke-width="2.4" />
</div>
</button>
</header>
</template>
+23
View File
@@ -5,6 +5,7 @@ import {
useNotificationsStore,
type PhoneNotificationDevice,
} from '@/stores/notifications'
import { usePhoneStore } from '@/stores/phone'
import {
DEFAULT_PHONE_PREFERENCES,
type PhonePreferencesV1,
@@ -104,4 +105,26 @@ describe('notifications store', () => {
expect(id).toBeNull()
expect(notifications.devicePreviews).toEqual([])
})
it('suppresses normal notifications during Focus but keeps critical alerts', () => {
const phone = usePhoneStore()
const notifications = useNotificationsStore()
phone.preferences.settings.focusMode = true
const normalId = notifications.show({
appId: 'mail',
text: 'Quiet message',
title: 'Mail',
})
const criticalId = notifications.show({
appId: 'clock',
critical: true,
text: 'Timer finished',
title: 'Clock',
})
expect(normalId).toBeNull()
expect(criticalId).not.toBeNull()
expect(notifications.current?.text).toBe('Timer finished')
})
})
+1
View File
@@ -123,6 +123,7 @@ export const useNotificationsStore = defineStore('notifications', () => {
console.error(`[Phone notifications] Unknown app: ${input.appId}`)
return null
}
if (!input.critical && preferences.settings.focusMode) return null
if (!input.critical && !appPreferences.enabled) {
return null
}
+31
View File
@@ -1030,6 +1030,31 @@ const defaultLocales: LocaleTree = {
},
},
},
ControlCenter: {
airplaneMode: 'Airplane Mode',
bluetooth: 'Bluetooth',
brightness: 'Brightness',
calculator: 'Calculator',
camera: 'Camera',
cellular: 'Cellular Data',
close: 'Close Control Center',
flashlight: 'Flashlight',
focus: 'Focus',
label: 'Control Center',
media: 'Media',
muteRingtone: 'Mute ringtone and notifications',
next: 'Next track',
notPlaying: 'Not Playing',
open: 'Open Control Center',
play: 'Play',
previous: 'Previous track',
quickActions: 'Quick actions',
rotationLock: 'Rotation Lock',
timer: 'Timer',
unmuteRingtone: 'Unmute ringtone and notifications',
volume: 'Volume',
wifi: 'Wi-Fi',
},
Common: {
add: 'Add',
cancel: 'Cancel',
@@ -1189,6 +1214,12 @@ export const usePhoneStore = defineStore('phone', {
this.preferences.settings[key] = value
this.saveDeviceNamespace('settings', this.preferences)
},
setAlertVolumes(value: number): void {
const volume = Math.min(100, Math.max(0, Math.round(value)))
this.preferences.settings.notificationVolume = volume
this.preferences.settings.ringtoneVolume = volume
this.saveDeviceNamespace('settings', this.preferences)
},
setSystemDarkMode(value: boolean): void {
this.systemDarkMode = value
},
+30
View File
@@ -13,6 +13,9 @@ describe('preferences', () => {
version: 1,
settings: {
appearanceMode: 'light',
bluetoothEnabled: false,
cellularEnabled: false,
focusMode: true,
notificationVolume: 45,
notificationDurationSeconds: 14,
notifications: {
@@ -20,11 +23,17 @@ describe('preferences', () => {
clock: { enabled: false, sounds: false },
},
phoneScale: 110,
rotationLocked: true,
screenBrightness: 64,
wallpaper: 'ember',
wifiEnabled: false,
},
}),
)
expect(value.settings.appearanceMode).toBe('light')
expect(value.settings.bluetoothEnabled).toBe(false)
expect(value.settings.cellularEnabled).toBe(false)
expect(value.settings.focusMode).toBe(true)
expect(value.settings.notificationVolume).toBe(45)
expect(value.settings.notificationDurationSeconds).toBe(14)
expect(value.settings.notifications.messages).toEqual({
@@ -40,7 +49,10 @@ describe('preferences', () => {
sounds: true,
})
expect(value.settings.phoneScale).toBe(110)
expect(value.settings.rotationLocked).toBe(true)
expect(value.settings.screenBrightness).toBe(64)
expect(value.settings.wallpaper).toBe('ember')
expect(value.settings.wifiEnabled).toBe(false)
})
it('clamps ranges and rejects unknown choices', () => {
@@ -54,6 +66,7 @@ describe('preferences', () => {
notificationDurationSeconds: 100,
phoneScale: 500,
ringtoneVolume: 120,
screenBrightness: -20,
},
}),
)
@@ -64,5 +77,22 @@ describe('preferences', () => {
expect(value.settings.notificationDurationSeconds).toBe(30)
expect(value.settings.phoneScale).toBe(150)
expect(value.settings.ringtoneVolume).toBe(100)
expect(value.settings.screenBrightness).toBe(10)
})
it('adds safe control center defaults to legacy version one preferences', () => {
const value = parsePhonePreferences(
JSON.stringify({ version: 1, settings: { airplaneMode: true } }),
)
expect(value.settings).toMatchObject({
airplaneMode: true,
bluetoothEnabled: true,
cellularEnabled: true,
focusMode: false,
rotationLocked: false,
screenBrightness: 100,
wifiEnabled: true,
})
})
})
+32
View File
@@ -30,6 +30,9 @@ export type PhonePreferencesV1 = {
settings: {
airplaneMode: boolean
appearanceMode: AppearanceMode
bluetoothEnabled: boolean
cellularEnabled: boolean
focusMode: boolean
frame: PhoneFrameId
notificationSound: NotificationSoundId
notificationDurationSeconds: number
@@ -38,8 +41,11 @@ export type PhonePreferencesV1 = {
phoneScale: number
ringtone: RingtoneId
ringtoneVolume: number
rotationLocked: boolean
screenBrightness: number
streamerMode: boolean
wallpaper: WallpaperId
wifiEnabled: boolean
}
version: 1
}
@@ -78,6 +84,9 @@ export const DEFAULT_PHONE_PREFERENCES: PhonePreferencesV1 = {
settings: {
airplaneMode: false,
appearanceMode: 'automatic',
bluetoothEnabled: true,
cellularEnabled: true,
focusMode: false,
frame: 'black',
notificationSound: 'chime',
notificationDurationSeconds: 10,
@@ -86,8 +95,11 @@ export const DEFAULT_PHONE_PREFERENCES: PhonePreferencesV1 = {
phoneScale: 100,
ringtone: 'skyline',
ringtoneVolume: 80,
rotationLocked: false,
screenBrightness: 100,
streamerMode: false,
wallpaper: 'midnight',
wifiEnabled: true,
},
version: 1,
}
@@ -163,6 +175,15 @@ export function parsePhonePreferences(raw: string | null): PhonePreferencesV1 {
APPEARANCE_MODE_IDS,
defaults.appearanceMode,
),
bluetoothEnabled: readBoolean(
settings.bluetoothEnabled,
defaults.bluetoothEnabled,
),
cellularEnabled: readBoolean(
settings.cellularEnabled,
defaults.cellularEnabled,
),
focusMode: readBoolean(settings.focusMode, defaults.focusMode),
frame: readChoice(settings.frame, PHONE_FRAME_IDS, defaults.frame),
notificationSound: readChoice(
settings.notificationSound,
@@ -199,12 +220,23 @@ export function parsePhonePreferences(raw: string | null): PhonePreferencesV1 {
0,
100,
),
rotationLocked: readBoolean(
settings.rotationLocked,
defaults.rotationLocked,
),
screenBrightness: readNumber(
settings.screenBrightness,
defaults.screenBrightness,
10,
100,
),
streamerMode: readBoolean(settings.streamerMode, defaults.streamerMode),
wallpaper: readChoice(
settings.wallpaper,
WALLPAPER_IDS,
defaults.wallpaper,
),
wifiEnabled: readBoolean(settings.wifiEnabled, defaults.wifiEnabled),
},
version: 1,
}
+12 -1
View File
@@ -22,6 +22,7 @@ import {
TimerReset,
} from 'lucide-vue-next'
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import AlarmEditor from '@/components/AlarmEditor.vue'
import AlarmSoundMenu from '@/components/AlarmSoundMenu.vue'
@@ -41,7 +42,10 @@ import {
const phone = usePhoneStore()
const clock = useClockStore()
const tab = ref<'world' | 'alarm' | 'stopwatch' | 'timer'>('world')
const route = useRoute()
const tab = ref<'world' | 'alarm' | 'stopwatch' | 'timer'>(
route.query.section === 'timer' ? 'timer' : 'world',
)
const alarmEditor = ref<
{ mode: 'create' } | { id: string; mode: 'edit' } | null
>(null)
@@ -202,6 +206,13 @@ watch([() => clock.stopwatchStartedAt, tab], ([startedAt, activeTab]) => {
: requestAnimationFrame(updateStopwatchFrame)
})
watch(
() => route.query.section,
(section) => {
if (section === 'timer') tab.value = 'timer'
},
)
onMounted(() => {
ticker = setInterval(() => {
now.value = Date.now()
+7
View File
@@ -19,6 +19,13 @@ Locales["en"] = {
phone = "Phone", phoneStatus = "Phone status", reset = "Reset",
save = "Save", search = "Search", send = "Send", start = "Start", stop = "Stop",
},
ControlCenter = {
airplaneMode = "Airplane Mode", bluetooth = "Bluetooth", brightness = "Brightness", calculator = "Calculator",
camera = "Camera", cellular = "Cellular Data", close = "Close Control Center", flashlight = "Flashlight",
focus = "Focus", label = "Control Center", media = "Media", muteRingtone = "Mute ringtone and notifications", next = "Next track", notPlaying = "Not Playing",
open = "Open Control Center", play = "Play", previous = "Previous track", quickActions = "Quick actions",
rotationLock = "Rotation Lock", timer = "Timer", unmuteRingtone = "Unmute ringtone and notifications", volume = "Volume", wifi = "Wi-Fi",
},
Notifications = { now = "now" },
LockScreen = {
label = "Lock Screen", flashlight = "Flashlight", camera = "Camera", swipeUp = "Swipe up to open",
+2 -2
View File
@@ -4,8 +4,8 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Sky Phone</title>
<script type="module" crossorigin src="./assets/sky-index-Bry4DTFa.js"></script>
<link rel="stylesheet" crossorigin href="./assets/sky-index-BKiDdWOy.css">
<script type="module" crossorigin src="./assets/sky-index-CzGvl3f3.js"></script>
<link rel="stylesheet" crossorigin href="./assets/sky-index-lJihApsN.css">
</head>
<body>
<div id="app"></div>