FIX - smooth hardware and Control Center volume

This commit is contained in:
Leon.Schmidt
2026-08-17 13:15:18 +02:00
parent 05444c84ec
commit 9fe47f8794
3 changed files with 264 additions and 52 deletions
+1 -1
View File
@@ -358,7 +358,7 @@ const hardwareAlertVolume = computed(() =>
const hardwareVolumeHudStyle = computed<CSSProperties>(
() =>
({
'--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)')
@@ -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')
})
})
+205 -51
View File
@@ -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<void> {
@@ -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(() => {
<section
v-if="opened"
class="control-center"
:class="{ 'control-center--slider-dragging': sliderDragActive }"
role="dialog"
aria-modal="true"
:aria-label="phone.t('ControlCenter.label')"
@@ -424,10 +558,7 @@ onBeforeUnmount(() => {
:colors="inactiveGlassColors"
:highlight="false"
:style="brightnessStyle"
@pointerdown="startBrightnessDrag"
@pointermove="dragBrightness"
@pointerup="finishBrightnessDrag"
@pointercancel="finishBrightnessDrag"
@pointerdown="startSliderDrag('brightness', $event)"
>
<span
class="control-center__slider-level"
@@ -454,10 +585,7 @@ onBeforeUnmount(() => {
:colors="inactiveGlassColors"
:highlight="false"
:style="volumeStyle"
@pointerdown="startVolumeDrag"
@pointermove="dragVolume"
@pointerup="finishVolumeDrag"
@pointercancel="finishVolumeDrag"
@pointerdown="startSliderDrag('volume', $event)"
>
<span
class="control-center__slider-level"
@@ -557,6 +685,16 @@ onBeforeUnmount(() => {
</div>
</nav>
</div>
<div
v-if="sliderDragActive"
class="control-center__slider-drag-surface"
aria-hidden="true"
@mousemove="continueSliderSurfaceDrag"
@mouseup="finishSliderSurfaceDrag"
@pointermove="continueSliderSurfaceDrag"
@pointerup="finishSliderSurfaceDrag"
></div>
</section>
</Transition>
</template>
@@ -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 {