ADD - activate phone hardware controls

Overlay accessible hit targets on the rendered phone frame for mute, volume, and power controls. Add the compact iOS volume HUD, localized labels, and full passcode locking when the device is explicitly locked.
This commit is contained in:
Leon.Schmidt
2026-08-17 05:00:41 +02:00
parent c9735e6fe7
commit a99becff87
6 changed files with 276 additions and 9 deletions
+135 -1
View File
@@ -330,6 +330,8 @@ const passcodeResetKey = ref(0)
const passcodeRetrySeconds = ref(0)
const passcodeVisible = ref(false)
const passcodeRequired = ref(false)
const previousHardwareAlertVolume = ref(75)
const hardwareVolumeHudVisible = ref(false)
const setupPreviewDismissed = ref(false)
const setupDevelopmentSkipped = ref(false)
const pendingUnlockRoute = ref<string | null>(null)
@@ -345,6 +347,19 @@ const setupRequired = computed(
developmentParameters.has('setupPreview') &&
!setupPreviewDismissed.value)),
)
const hardwareAlertVolume = computed(() =>
Math.round(
(phone.preferences.settings.notificationVolume +
phone.preferences.settings.ringtoneVolume) /
2,
),
)
const hardwareVolumeHudStyle = computed<CSSProperties>(
() =>
({
'--phone-hardware-volume-fill': `${32 + hardwareAlertVolume.value * 0.76}px`,
}) as CSSProperties,
)
const systemColorScheme = window.matchMedia('(prefers-color-scheme: dark)')
const viewportScale = ref(getViewportScale())
const browserDevicePixelRatio = ref(window.devicePixelRatio || 1)
@@ -400,6 +415,7 @@ let companiesChangeTimer: number | undefined
let pendingCompaniesChange: CompanyChangedPayload | null = null
let unlockTimer: number | undefined
let passcodeLockTimer: number | undefined
let hardwareVolumeHudTimer: number | undefined
let unlockedServicesIdle: number | undefined
let phoneClosePending = false
let simPickerClosePending = false
@@ -1264,11 +1280,51 @@ function lockPhone(): void {
passcodeVisible.value = false
passcodeBusy.value = false
passcodeError.value = ''
passcodeRequired.value = false
passcodeRequired.value = phone.security.enabled
pendingUnlockRoute.value = null
isLocked.value = true
}
function showHardwareVolumeHud(): void {
hardwareVolumeHudVisible.value = true
if (hardwareVolumeHudTimer !== undefined) {
window.clearTimeout(hardwareVolumeHudTimer)
}
hardwareVolumeHudTimer = window.setTimeout(() => {
hardwareVolumeHudVisible.value = false
hardwareVolumeHudTimer = undefined
}, 1300)
}
function toggleHardwareLock(): void {
if (setupRequired.value) return
if (isLocked.value) {
unlockPhone()
return
}
lockPhone()
}
function changeHardwareAlertVolume(delta: number): void {
if (setupRequired.value) return
const volume = Math.min(100, Math.max(0, hardwareAlertVolume.value + delta))
if (volume > 0) previousHardwareAlertVolume.value = volume
phone.setAlertVolumes(volume)
showHardwareVolumeHud()
}
function toggleHardwareAlertMute(): void {
if (setupRequired.value) return
if (hardwareAlertVolume.value > 0) {
previousHardwareAlertVolume.value = hardwareAlertVolume.value
phone.setAlertVolumes(0)
showHardwareVolumeHud()
return
}
phone.setAlertVolumes(previousHardwareAlertVolume.value)
showHardwareVolumeHud()
}
function returnToActiveCall(): void {
if (!calls.activeCall) return
controlCenterOpened.value = false
@@ -1394,6 +1450,11 @@ watch(
passcodeBusy.value = false
passcodeError.value = ''
passcodeRequired.value = false
hardwareVolumeHudVisible.value = false
if (hardwareVolumeHudTimer !== undefined) {
window.clearTimeout(hardwareVolumeHudTimer)
hardwareVolumeHudTimer = undefined
}
pendingUnlockRoute.value = null
unlockedServicesLoaded.value = false
if (passcodeLockTimer !== undefined) {
@@ -1443,6 +1504,9 @@ onBeforeUnmount(() => {
}
if (unlockTimer !== undefined) window.clearTimeout(unlockTimer)
if (passcodeLockTimer !== undefined) window.clearInterval(passcodeLockTimer)
if (hardwareVolumeHudTimer !== undefined) {
window.clearTimeout(hardwareVolumeHudTimer)
}
window.removeEventListener('message', onMessage)
window.removeEventListener('keydown', onKeydown)
window.removeEventListener('resize', updateViewportScale)
@@ -1503,6 +1567,45 @@ onBeforeUnmount(() => {
}"
:aria-label="phone.t('Common.phone')"
>
<button
type="button"
class="phone-hardware-button phone-hardware-button--action"
:aria-label="
phone.t(
hardwareAlertVolume > 0
? 'HardwareButtons.mute'
: 'HardwareButtons.unmute',
)
"
:aria-pressed="hardwareAlertVolume === 0"
:disabled="setupRequired"
@click="toggleHardwareAlertMute"
></button>
<button
type="button"
class="phone-hardware-button phone-hardware-button--volume-up"
:aria-label="phone.t('HardwareButtons.volumeUp')"
:disabled="setupRequired"
@click="changeHardwareAlertVolume(10)"
></button>
<button
type="button"
class="phone-hardware-button phone-hardware-button--volume-down"
:aria-label="phone.t('HardwareButtons.volumeDown')"
:disabled="setupRequired"
@click="changeHardwareAlertVolume(-10)"
></button>
<button
type="button"
class="phone-hardware-button phone-hardware-button--power"
:aria-label="
phone.t(
isLocked ? 'HardwareButtons.unlock' : 'HardwareButtons.lock',
)
"
:disabled="setupRequired"
@click="toggleHardwareLock"
></button>
<div
class="phone-screen"
:class="{
@@ -1511,6 +1614,37 @@ onBeforeUnmount(() => {
[`phone-app--${phone.preferences.settings.graphicsMode}`]: true,
}"
>
<Transition name="phone-volume-hud">
<div
v-if="hardwareVolumeHudVisible"
class="phone-volume-hud"
:style="hardwareVolumeHudStyle"
role="status"
aria-live="polite"
:aria-label="
phone.t('HardwareButtons.volumeLevel', {
value: String(hardwareAlertVolume),
})
"
>
<svg
class="phone-volume-hud__icon"
aria-hidden="true"
viewBox="0 0 24 24"
>
<path d="M4 9v6h4l5 4V5L8 9H4Z" />
<path
v-if="hardwareAlertVolume > 0"
d="M16 8.2c1.1 1 1.7 2.2 1.7 3.8s-.6 2.8-1.7 3.8"
/>
<path v-else d="m16.2 9.2 4.6 4.6m0-4.6-4.6 4.6" />
</svg>
<span
class="phone-volume-hud__level"
aria-hidden="true"
></span>
</div>
</Transition>
<k-app
theme="ios"
:dark="phone.isDarkMode"
@@ -3,15 +3,15 @@ import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
const source = readFileSync(new URL('./App.vue', import.meta.url), 'utf8')
const styles = readFileSync(
new URL('./assets/main.css', import.meta.url),
'utf8',
)
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("developmentParameters.has('lockScreenPreview')")
expect(source).toContain(': !isDevelopment || developmentLockScreenPreview')
expect(source).toContain("developmentParameters.has('setupPreview')")
})
@@ -22,7 +22,7 @@ describe('browser development preview contract', () => {
expect(source).toContain('else loadUnlockedPhoneData()')
})
it('requires the passcode only for the first unlock of a device session', () => {
it('requires the passcode again after a full device lock', () => {
expect(source).toContain('const passcodeRequired = ref(false)')
expect(source).toContain(
'if (phone.security.enabled && passcodeRequired.value)',
@@ -31,7 +31,7 @@ describe('browser development preview contract', () => {
'passcodeRequired.value = isLocked.value && phone.security.enabled',
)
expect(source).toMatch(
/function lockPhone\(\): void \{[\s\S]*?passcodeRequired\.value = false[\s\S]*?isLocked\.value = true/,
/function lockPhone\(\): void \{[\s\S]*?passcodeRequired\.value = phone\.security\.enabled[\s\S]*?isLocked\.value = true/,
)
})
@@ -41,4 +41,24 @@ describe('browser development preview contract', () => {
)
expect(source).toContain(':device-pixel-ratio="browserDevicePixelRatio"')
})
it('maps the visible device side controls to phone actions', () => {
expect(source).toContain('@click="toggleHardwareAlertMute"')
expect(source).toContain('@click="changeHardwareAlertVolume(10)"')
expect(source).toContain('@click="changeHardwareAlertVolume(-10)"')
expect(source).toContain('@click="toggleHardwareLock"')
expect(source).toMatch(
/function toggleHardwareLock\(\): void \{[\s\S]*?unlockPhone\(\)[\s\S]*?lockPhone\(\)/,
)
expect(source).toMatch(
/function changeHardwareAlertVolume\(delta: number\): void \{[\s\S]*?phone\.setAlertVolumes\(volume\)/,
)
expect(styles).toMatch(
/\.phone-hardware-button\s*\{[\s\S]*?position:\s*absolute;[\s\S]*?z-index:\s*101;/,
)
expect(styles).toContain('.phone-hardware-button--power')
expect(source).toContain('class="phone-volume-hud"')
expect(source).toContain('showHardwareVolumeHud()')
expect(styles).toContain('.phone-volume-hud__level')
})
})
+94
View File
@@ -322,6 +322,100 @@ button {
object-fit: fill;
pointer-events: none;
}
.phone-hardware-button {
position: absolute;
z-index: 101;
width: 26px;
margin: 0;
padding: 0;
border: 0;
appearance: none;
background: transparent;
cursor: pointer;
touch-action: manipulation;
}
.phone-hardware-button:disabled {
cursor: default;
}
.phone-hardware-button:focus-visible {
outline: 2px solid #fff;
outline-offset: -6px;
}
.phone-hardware-button--action {
top: 176px;
left: -7px;
height: 44px;
}
.phone-hardware-button--volume-up {
top: 238px;
left: -7px;
height: 74px;
}
.phone-hardware-button--volume-down {
top: 315px;
left: -7px;
height: 74px;
}
.phone-hardware-button--power {
top: 252px;
right: -7px;
height: 62px;
}
.phone-volume-hud {
position: absolute;
z-index: 220;
top: 188px;
left: 6px;
width: 30px;
height: 108px;
overflow: hidden;
border: 1px solid rgb(255 255 255 / 16%);
border-radius: 15px;
color: #0a84ff;
background: rgb(47 47 51 / 98%);
box-shadow: 0 8px 24px rgb(0 0 0 / 28%);
pointer-events: none;
}
.phone-volume-hud__icon {
position: absolute;
z-index: 2;
bottom: 8px;
left: 50%;
width: 16px;
height: 16px;
fill: currentColor;
stroke: currentColor;
stroke-width: 1.8;
stroke-linecap: round;
stroke-linejoin: round;
transform: translateX(-50%);
}
.phone-volume-hud__level {
position: absolute;
right: 0;
bottom: 0;
left: 0;
height: var(--phone-hardware-volume-fill);
background: #fff;
transition: height 180ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.phone-volume-hud-enter-active,
.phone-volume-hud-leave-active {
transition:
opacity 180ms ease,
transform 240ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.phone-volume-hud-enter-from,
.phone-volume-hud-leave-to {
opacity: 0;
transform: translateX(-10px) scale(0.92);
}
@media (prefers-reduced-motion: reduce) {
.phone-volume-hud,
.phone-volume-hud__level {
transition: none;
}
}
.phone-screen {
position: relative;
container-type: size;
+9
View File
@@ -4533,6 +4533,15 @@ const defaultLocales: LocaleTree = {
rateLimited: 'Too many attempts. Please wait.',
},
},
HardwareButtons: {
lock: 'Lock phone',
mute: 'Mute ringtone and notifications',
unlock: 'Unlock phone',
unmute: 'Unmute ringtone and notifications',
volumeDown: 'Volume down',
volumeLevel: 'Volume {value} percent',
volumeUp: 'Volume up',
},
Home: {
appLibrary: 'App Library',
appLibrarySearch: 'Search apps',
+5
View File
@@ -411,6 +411,11 @@ merge(german, {
rateLimited = "Zu viele Versuche. Bitte warte.",
},
},
HardwareButtons = {
lock = "Handy sperren", mute = "Klingelton und Mitteilungen stummschalten", unlock = "Handy entsperren",
unmute = "Klingelton und Mitteilungen einschalten", volumeDown = "Leiser",
volumeLevel = "Lautstärke {value} Prozent", volumeUp = "Lauter",
},
Home = {
appLibrary = "App-Mediathek", appLibrarySearch = "Apps suchen", allApps = "Alle Apps",
apps = "Apps", dock = "Dock", noApps = "Keine Apps gefunden",
+5
View File
@@ -155,6 +155,11 @@ Locales["en"] = {
tryAgain = "Try again in {seconds} seconds", rateLimited = "Too many attempts. Please wait.",
},
},
HardwareButtons = {
lock = "Lock phone", mute = "Mute ringtone and notifications", unlock = "Unlock phone",
unmute = "Unmute ringtone and notifications", volumeDown = "Volume down",
volumeLevel = "Volume {value} percent", volumeUp = "Volume up",
},
Home = {
appLibrary = "App Library", appLibrarySearch = "Search apps", allApps = "All Apps", apps = "Apps",
dock = "Dock", noApps = "No apps found", removeApp = "Remove {app} from Home Screen", addToHome = "Add {app} to Home Screen", addPage = "Add Home Screen page", deletePage = "Delete current Home Screen page", removedFromHome = "Removed from Home Screen", page = "Page", pages = "Home screen pages",