From 9fe47f87940c1543e71414f1bd15b2b965d9c89c Mon Sep 17 00:00:00 2001 From: "Leon.Schmidt" Date: Mon, 17 Aug 2026 13:15:18 +0200 Subject: [PATCH] FIX - smooth hardware and Control Center volume --- frontend/src/App.vue | 2 +- .../PhoneControlCenter.contract.test.ts | 58 ++++ .../src/components/PhoneControlCenter.vue | 256 ++++++++++++++---- 3 files changed, 264 insertions(+), 52 deletions(-) create mode 100644 frontend/src/components/PhoneControlCenter.contract.test.ts diff --git a/frontend/src/App.vue b/frontend/src/App.vue index ba1707a..8eaefcc 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -358,7 +358,7 @@ const hardwareAlertVolume = computed(() => const hardwareVolumeHudStyle = computed( () => ({ - '--phone-hardware-volume-fill': `${32 + hardwareAlertVolume.value * 0.76}px`, + '--phone-hardware-volume-fill': `${hardwareAlertVolume.value}%`, }) as CSSProperties, ) const systemColorScheme = window.matchMedia('(prefers-color-scheme: dark)') diff --git a/frontend/src/components/PhoneControlCenter.contract.test.ts b/frontend/src/components/PhoneControlCenter.contract.test.ts new file mode 100644 index 0000000..b62d0f3 --- /dev/null +++ b/frontend/src/components/PhoneControlCenter.contract.test.ts @@ -0,0 +1,58 @@ +import { readFileSync } from 'node:fs' + +import { describe, expect, it } from 'vitest' + +const source = readFileSync( + new URL('./PhoneControlCenter.vue', import.meta.url), + 'utf8', +) +const appSource = readFileSync(new URL('../App.vue', import.meta.url), 'utf8') + +describe('phone control center sliders', () => { + it('tracks brightness and volume drags at window scope', () => { + expect(source).toContain( + "import { bindPointerDragSession } from '@/utils/pointerDragSession'", + ) + expect(source).toContain("startSliderDrag('brightness', $event)") + expect(source).toContain("startSliderDrag('volume', $event)") + expect(source).toContain('bindPointerDragSession(window, pointerId') + expect(source).toContain( + "window.addEventListener('mousemove', onMouseMove, true)", + ) + expect(source).toContain( + "window.addEventListener('mouseup', onMouseUp, true)", + ) + expect(source).toContain('drag.target.getBoundingClientRect()') + expect(source).toContain('class="control-center__slider-drag-surface"') + expect(source).toContain('@pointermove="continueSliderSurfaceDrag"') + expect(source).toContain('clientY - drag.lastClientY') + expect(source).toContain( + 'move: (moveEvent) => queueSliderDrag(moveEvent.clientY, drag)', + ) + expect(source).toContain('window.requestAnimationFrame') + expect(source).toContain( + "const initialValue = kind === 'brightness' ? brightness.value : volume.value", + ) + expect(source).not.toContain('target.setPointerCapture(pointerId)') + expect(source).not.toContain('@pointermove="dragBrightness"') + expect(source).not.toContain('@pointermove="dragVolume"') + }) + + it('persists the final value and cleans up interrupted drags', () => { + expect(source).toContain("phone.setPreference('screenBrightness'") + expect(source).toContain('phone.setAlertVolumes(volume.value)') + expect(source).toMatch( + /watch\([\s\S]*?\(opened\) => \{[\s\S]*?if \(!opened\) \{[\s\S]*?finishSliderDrag\(true\)/, + ) + expect(source).toMatch( + /onBeforeUnmount\(\(\) => \{[\s\S]*?finishSliderDrag\(true\)/, + ) + }) + + it('renders a completely empty hardware volume fill when muted', () => { + expect(appSource).toContain( + "'--phone-hardware-volume-fill': `${hardwareAlertVolume.value}%`", + ) + expect(appSource).not.toContain('32 + hardwareAlertVolume.value') + }) +}) diff --git a/frontend/src/components/PhoneControlCenter.vue b/frontend/src/components/PhoneControlCenter.vue index cf8489d..ebe4624 100644 --- a/frontend/src/components/PhoneControlCenter.vue +++ b/frontend/src/components/PhoneControlCenter.vue @@ -34,6 +34,7 @@ import { useMusicStore } from '@/stores/music' import { useEasyShareStore } from '@/stores/easyshare' import type { EasySharePayload } from '@/types/easyshare' import { nuiCall } from '@/utils/nui' +import { bindPointerDragSession } from '@/utils/pointerDragSession' const props = defineProps<{ opened: boolean }>() const emit = defineEmits<{ close: [] }>() @@ -62,6 +63,21 @@ const flashlightActive = ref(false) const flashlightPending = ref(false) const easySharePending = ref(false) const previousAlertVolume = ref(volume.value || 75) +const sliderDragActive = ref(false) +type SliderKind = 'brightness' | 'volume' +type SliderDrag = { + animationFrame: number | null + currentValue: number + kind: SliderKind + lastClientY: number + pendingClientY: number + pointerId: number + pointerType: string + stopMouse: () => void + stopPointer: () => void + target: HTMLElement +} +let sliderDrag: SliderDrag | null = null const inactiveGlassColors = { bgIos: 'bg-[rgba(72,72,74,0.58)]', @@ -142,59 +158,172 @@ function toggleAlertMute(): void { 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 sliderRange(kind: SliderKind): { maximum: number; minimum: number } { + return kind === 'brightness' + ? { maximum: 100, minimum: 10 } + : { maximum: 100, minimum: 0 } } -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)) +function applySliderValue(drag: SliderDrag, value: number): void { + drag.currentValue = value + if (drag.kind === 'brightness') { + brightness.value = value + phone.preferences.settings.screenBrightness = value return - const nextValue = pointerRangeValue(event, 10, 100) - brightness.value = nextValue - phone.preferences.settings.screenBrightness = nextValue + } + volume.value = value } -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 updateSliderDrag(clientY: number, drag: SliderDrag): void { + const rect = drag.target.getBoundingClientRect() + const { maximum, minimum } = sliderRange(drag.kind) + const deltaY = clientY - drag.lastClientY + drag.lastClientY = clientY + const nextValue = + Math.round( + Math.min( + maximum, + Math.max( + minimum, + drag.currentValue - (deltaY / rect.height) * (maximum - minimum), + ), + ) * 100, + ) / 100 + applySliderValue(drag, nextValue) } -function startVolumeDrag(event: PointerEvent): void { - ;(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId) - volume.value = pointerRangeValue(event, 0, 100) +function queueSliderDrag(clientY: number, drag: SliderDrag): void { + drag.pendingClientY = clientY + if (drag.animationFrame !== null) return + drag.animationFrame = window.requestAnimationFrame(() => { + drag.animationFrame = null + if (sliderDrag !== drag) return + updateSliderDrag(drag.pendingClientY, drag) + }) } -function dragVolume(event: PointerEvent): void { - if (!(event.currentTarget as HTMLElement).hasPointerCapture(event.pointerId)) +function flushSliderDrag(clientY: number, drag: SliderDrag): void { + drag.pendingClientY = clientY + if (drag.animationFrame !== null) { + window.cancelAnimationFrame(drag.animationFrame) + drag.animationFrame = null + } + updateSliderDrag(drag.pendingClientY, drag) +} + +function cancelSliderAnimation(drag: SliderDrag): void { + if (drag.animationFrame === null) return + window.cancelAnimationFrame(drag.animationFrame) + drag.animationFrame = null +} + +function bindSliderMouseFallback(drag: SliderDrag): () => void { + let active = true + const cleanup = (): void => { + if (!active) return + active = false + window.removeEventListener('mousemove', onMouseMove, true) + window.removeEventListener('mouseup', onMouseUp, true) + window.removeEventListener('blur', onBlur, true) + } + const onMouseMove = (event: MouseEvent): void => { + if (!active || sliderDrag !== drag) return + queueSliderDrag(event.clientY, drag) + } + const onMouseUp = (event: MouseEvent): void => { + if (!active || sliderDrag !== drag) return + flushSliderDrag(event.clientY, drag) + cleanup() + finishSliderDrag(true) + } + const onBlur = (): void => { + if (!active || sliderDrag !== drag) return + cleanup() + finishSliderDrag(true) + } + + window.addEventListener('mousemove', onMouseMove, true) + window.addEventListener('mouseup', onMouseUp, true) + window.addEventListener('blur', onBlur, true) + return cleanup +} + +function continueSliderSurfaceDrag(event: MouseEvent | PointerEvent): void { + const drag = sliderDrag + if (!drag) return + event.preventDefault() + queueSliderDrag(event.clientY, drag) +} + +function finishSliderSurfaceDrag(event: MouseEvent | PointerEvent): void { + const drag = sliderDrag + if (!drag) return + event.preventDefault() + flushSliderDrag(event.clientY, drag) + finishSliderDrag(true) +} + +function finishSliderDrag(save: boolean): void { + const drag = sliderDrag + if (!drag) return + sliderDrag = null + sliderDragActive.value = false + cancelSliderAnimation(drag) + drag.stopPointer() + drag.stopMouse() + if (!save) return + if (drag.kind === 'brightness') { + const nextValue = Math.round(brightness.value) + brightness.value = nextValue + phone.preferences.settings.screenBrightness = nextValue + phone.setPreference('screenBrightness', nextValue) return - volume.value = pointerRangeValue(event, 0, 100) + } + const nextValue = Math.round(volume.value) + volume.value = nextValue + if (nextValue > 0) previousAlertVolume.value = nextValue + phone.setAlertVolumes(nextValue) } -function finishVolumeDrag(event: PointerEvent): void { +function startSliderDrag(kind: SliderKind, event: PointerEvent): void { + if (event.button !== 0) return + finishSliderDrag(true) 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) + const pointerId = event.pointerId + const initialValue = kind === 'brightness' ? brightness.value : volume.value + const drag: SliderDrag = { + animationFrame: null, + currentValue: initialValue, + kind, + lastClientY: event.clientY, + pendingClientY: event.clientY, + pointerId, + pointerType: event.pointerType, + stopMouse: () => {}, + stopPointer: () => {}, + target, + } + sliderDrag = drag + sliderDragActive.value = true + applySliderValue(drag, initialValue) + drag.stopPointer = bindPointerDragSession(window, pointerId, { + cancel: () => { + if (drag.pointerType === 'touch') { + finishSliderDrag(true) + return + } + drag.stopPointer() + drag.stopPointer = () => {} + }, + move: (moveEvent) => queueSliderDrag(moveEvent.clientY, drag), + up: (upEvent) => { + flushSliderDrag(upEvent.clientY, drag) + finishSliderDrag(true) + }, + }) + if (event.pointerType !== 'touch') { + drag.stopMouse = bindSliderMouseFallback(drag) + } } async function toggleFlashlight(): Promise { @@ -237,7 +366,10 @@ function openTimer(): void { watch( () => props.opened, (opened) => { - if (!opened) return + if (!opened) { + finishSliderDrag(true) + return + } brightness.value = phone.preferences.settings.screenBrightness volume.value = Math.round( (phone.preferences.settings.notificationVolume + @@ -250,6 +382,7 @@ watch( ) onBeforeUnmount(() => { + finishSliderDrag(true) if (flashlightActive.value) void nuiCall('camera:setFlash', { enabled: false }) }) @@ -260,6 +393,7 @@ onBeforeUnmount(() => { @@ -583,7 +721,9 @@ onBeforeUnmount(() => { linear-gradient(180deg, #191b23 0%, #11131a 100%); } -@supports ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) { +@supports ( + (backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px)) +) { .control-center__backdrop { background: radial-gradient(circle at 16% 12%, rgb(77 96 135 / 14%), transparent 46%), @@ -607,6 +747,15 @@ onBeforeUnmount(() => { transform-origin: calc(100% - 38px) 18px; } +.control-center__slider-drag-surface { + position: absolute; + z-index: 20; + inset: 0; + cursor: ns-resize; + pointer-events: auto; + touch-action: none; +} + .control-center__top-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); @@ -847,7 +996,12 @@ onBeforeUnmount(() => { left: 0; height: var(--control-level); background: rgba(255, 255, 255, 0.96); - transition: height 80ms linear; + transition: height 160ms cubic-bezier(0.2, 0.8, 0.2, 1); + will-change: height; +} + +.control-center--slider-dragging .control-center__slider-level { + transition: none; } .control-center__range {