From 14b7fbf014e4df762f179d4e2e8d3fe5eb502adf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucas=20T=C3=A4kker?= Date: Tue, 6 Oct 2026 21:34:08 +0200 Subject: [PATCH] Animated mobile player dismissal --- app/components/Player/Fullscreen.vue | 142 ++++++++++++++++++++++++++- app/ui/ToastContainer.vue | 2 + 2 files changed, 142 insertions(+), 2 deletions(-) diff --git a/app/components/Player/Fullscreen.vue b/app/components/Player/Fullscreen.vue index 0f8dbf8..8195c9e 100644 --- a/app/components/Player/Fullscreen.vue +++ b/app/components/Player/Fullscreen.vue @@ -7,6 +7,115 @@ const mouseMovedRecently = ref(true) const mobileView = ref(false) const showMobileLyrics = ref(false) const queueOpen = useState("queueOpen", () => false) +const dragOffset = ref(0) +const isDragging = ref(false) +let dragPointerId: number | null = null +let dragStartX = 0 +let dragStartY = 0 +let dragStartedAt = 0 +let clickSuppressionTimer: number | undefined +let closeAnimationTimer: number | undefined + +function suppressClickThrough(event: MouseEvent) { + event.preventDefault() + event.stopImmediatePropagation() + if (clickSuppressionTimer !== undefined) + window.clearTimeout(clickSuppressionTimer) + clickSuppressionTimer = undefined + document.removeEventListener("click", suppressClickThrough, true) +} + +function startDrag(event: PointerEvent) { + if (!mobileView.value || !event.isPrimary) return + if (event.pointerType === "mouse" && event.button !== 0) return + if (event.clientY > window.innerHeight * 0.16) return + + const target = event.target + if ( + target instanceof Element && + target.closest( + "button, a, input, [role='slider'], .slider, .btns, .volume-slider, .mobile-tabs", + ) + ) + return + + dragPointerId = event.pointerId + dragStartX = event.clientX + dragStartY = event.clientY + dragStartedAt = performance.now() + const element = event.currentTarget as HTMLElement + element.setPointerCapture(event.pointerId) +} + +function moveDrag(event: PointerEvent) { + if (dragPointerId !== event.pointerId) return + + const deltaX = event.clientX - dragStartX + const deltaY = event.clientY - dragStartY + if (Math.abs(deltaX) > Math.abs(deltaY)) return + if (deltaY <= 0) { + dragOffset.value = 0 + return + } + + event.preventDefault() + isDragging.value = true + dragOffset.value = deltaY +} + +function endDrag(event: PointerEvent) { + if (dragPointerId !== event.pointerId) return + + const deltaY = Math.max(0, event.clientY - dragStartY) + const deltaX = Math.abs(event.clientX - dragStartX) + const elapsed = performance.now() - dragStartedAt + const closeThreshold = Math.max(96, window.innerHeight * 0.16) + const shouldClose = + deltaX <= deltaY && + (deltaY >= closeThreshold || (deltaY >= 56 && elapsed < 220)) + + dragPointerId = null + if (shouldClose) { + document.addEventListener("click", suppressClickThrough, true) + clickSuppressionTimer = window.setTimeout(() => { + document.removeEventListener("click", suppressClickThrough, true) + clickSuppressionTimer = undefined + }, 400) + isDragging.value = false + dragOffset.value = window.innerHeight + if (closeAnimationTimer !== undefined) + window.clearTimeout(closeAnimationTimer) + closeAnimationTimer = window.setTimeout(finishCloseAnimation, 400) + return + } + + isDragging.value = false + dragOffset.value = 0 +} + +function finishCloseAnimation() { + if (closeAnimationTimer !== undefined) + window.clearTimeout(closeAnimationTimer) + closeAnimationTimer = undefined + closeFullscreen() + dragOffset.value = 0 +} + +function onFullscreenTransitionEnd(event: TransitionEvent) { + if ( + closeAnimationTimer !== undefined && + event.target === event.currentTarget && + event.propertyName === "transform" + ) + finishCloseAnimation() +} + +function cancelDrag(event: PointerEvent) { + if (dragPointerId !== event.pointerId) return + dragPointerId = null + isDragging.value = false + dragOffset.value = 0 +} onMounted(() => { let timeoutId: NodeJS.Timeout @@ -36,6 +145,11 @@ onMounted(() => { window.removeEventListener("mousemove", handleMouseMove) window.removeEventListener("keydown", handleKeydown) mobileQuery.removeEventListener("change", updateMobileView) + document.removeEventListener("click", suppressClickThrough, true) + if (clickSuppressionTimer !== undefined) + window.clearTimeout(clickSuppressionTimer) + if (closeAnimationTimer !== undefined) + window.clearTimeout(closeAnimationTimer) }) }) @@ -49,7 +163,9 @@ nuxtApp.hook("page:finish", () => {