mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-05-11 18:10:49 +08:00
279 lines
7.9 KiB
TypeScript
279 lines
7.9 KiB
TypeScript
import { ref, computed, onMounted, onBeforeUnmount, readonly, watch } from 'vue'
|
||
import { useDisplay } from 'vuetify'
|
||
import { usePWA } from './usePWA'
|
||
|
||
// 下拉手势配置类型
|
||
export interface PullDownConfig {
|
||
START_THRESHOLD: number // 开始下拉的最小距离
|
||
SHOW_INDICATOR: number // 显示指示器的距离
|
||
TRIGGER_THRESHOLD: number // 触发回调的距离
|
||
MAX_PULL_DISTANCE: number // 最大下拉距离
|
||
PULL_RESISTANCE: number // 下拉阻力系数
|
||
CONTENT_FOLLOW_RATIO: number // 页面内容跟随比例
|
||
TOLERANCE: number // 手指抖动容忍度
|
||
}
|
||
|
||
// 下拉手势选项
|
||
export interface PullDownOptions {
|
||
config?: Partial<PullDownConfig>
|
||
// 检查是否可以使用下拉手势的函数
|
||
canUsePullGesture?: () => boolean
|
||
// 触发回调
|
||
onTrigger?: () => void
|
||
// 是否启用(默认true)
|
||
enabled?: boolean
|
||
}
|
||
|
||
// 默认配置
|
||
const DEFAULT_CONFIG: PullDownConfig = {
|
||
START_THRESHOLD: 20,
|
||
SHOW_INDICATOR: 60,
|
||
TRIGGER_THRESHOLD: 100,
|
||
MAX_PULL_DISTANCE: 200,
|
||
PULL_RESISTANCE: 0.75,
|
||
CONTENT_FOLLOW_RATIO: 0.4,
|
||
TOLERANCE: 80,
|
||
}
|
||
|
||
export function usePullDownGesture(options: PullDownOptions = {}) {
|
||
const display = useDisplay()
|
||
const { appMode } = usePWA()
|
||
|
||
// 合并配置
|
||
const config = { ...DEFAULT_CONFIG, ...options.config }
|
||
|
||
// 状态管理
|
||
const isPulling = ref(false)
|
||
const startY = ref(0)
|
||
const pullDistance = ref(0)
|
||
const initialScrollTop = ref(0)
|
||
const hasDialogOpen = ref(false)
|
||
const lastDialogCheckTime = ref(0)
|
||
const DIALOG_CHECK_INTERVAL = 500
|
||
|
||
// 计算属性
|
||
const contentTransform = computed(() => {
|
||
if (!isPulling.value || pullDistance.value <= 0) return 'translateY(0)'
|
||
const moveDistance = pullDistance.value * config.CONTENT_FOLLOW_RATIO
|
||
return `translateY(${moveDistance}px)`
|
||
})
|
||
|
||
const contentTransition = computed(() => {
|
||
return isPulling.value ? 'none' : 'transform 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94)'
|
||
})
|
||
|
||
const showPullIndicator = computed(() => {
|
||
return isPulling.value && pullDistance.value >= config.SHOW_INDICATOR
|
||
})
|
||
|
||
const indicatorRotation = computed(() => {
|
||
if (!isPulling.value) return 0
|
||
const progress = Math.min(
|
||
(pullDistance.value - config.SHOW_INDICATOR) / (config.TRIGGER_THRESHOLD - config.SHOW_INDICATOR),
|
||
1,
|
||
)
|
||
return progress * 180
|
||
})
|
||
|
||
const indicatorOpacity = computed(() => {
|
||
if (!isPulling.value) return 0
|
||
const progress = Math.min(
|
||
(pullDistance.value - config.SHOW_INDICATOR) / (config.TRIGGER_THRESHOLD - config.SHOW_INDICATOR),
|
||
1,
|
||
)
|
||
return 0.7 + progress * 0.3
|
||
})
|
||
|
||
const indicatorTransform = computed(() => {
|
||
return `translate(-50%, ${Math.min(60 + pullDistance.value - config.SHOW_INDICATOR, 70)}px)`
|
||
})
|
||
|
||
// 弹窗检测函数
|
||
const hasOpenDialog = (excludeSelector?: string) => {
|
||
try {
|
||
const dialogSelectors = [
|
||
'.v-overlay--active:not(.v-overlay--scroll-blocked)',
|
||
'.v-dialog--active',
|
||
'.v-menu--active',
|
||
'.v-bottom-sheet--active',
|
||
'.v-snackbar--active',
|
||
'[role="dialog"]:not([style*="display: none"])',
|
||
'.modal:not(.d-none):not([style*="display: none"])',
|
||
'[aria-modal="true"]:not([style*="display: none"])',
|
||
]
|
||
|
||
for (const selector of dialogSelectors) {
|
||
const elements = document.querySelectorAll(selector)
|
||
if (elements.length > 0) {
|
||
// 如果需要排除特定元素(如QuickAccess面板)
|
||
if (excludeSelector && elements.length === 1) {
|
||
const element = elements[0]
|
||
if (element.closest(excludeSelector)) {
|
||
continue
|
||
}
|
||
}
|
||
return true
|
||
}
|
||
}
|
||
|
||
return false
|
||
} catch (error) {
|
||
console.warn('检测弹窗状态时出错:', error)
|
||
return true
|
||
}
|
||
}
|
||
|
||
// 事件处理函数
|
||
const handleTouchStart = (event: TouchEvent) => {
|
||
if (!appMode.value || !display.mdAndDown.value || !options.enabled) return
|
||
|
||
// 检查是否可以使用下拉手势
|
||
if (options.canUsePullGesture && !options.canUsePullGesture()) return
|
||
|
||
// 检查是否有弹窗打开
|
||
hasDialogOpen.value = hasOpenDialog('.quick-access-panel')
|
||
lastDialogCheckTime.value = Date.now()
|
||
|
||
if (hasDialogOpen.value) return
|
||
|
||
const touch = event.touches[0]
|
||
startY.value = touch.clientY
|
||
|
||
// 重置下拉状态
|
||
isPulling.value = false
|
||
pullDistance.value = 0
|
||
|
||
// 记录开始时的滚动位置
|
||
initialScrollTop.value = window.scrollY || document.documentElement.scrollTop || 0
|
||
}
|
||
|
||
const handleTouchMove = (event: TouchEvent) => {
|
||
if (!appMode.value || !display.mdAndDown.value || !options.enabled) return
|
||
|
||
// 检查是否可以使用下拉手势
|
||
if (options.canUsePullGesture && !options.canUsePullGesture()) return
|
||
|
||
// 只在必要时重新检测弹窗
|
||
const currentTime = Date.now()
|
||
if (currentTime - lastDialogCheckTime.value > DIALOG_CHECK_INTERVAL) {
|
||
hasDialogOpen.value = hasOpenDialog('.quick-access-panel')
|
||
lastDialogCheckTime.value = currentTime
|
||
}
|
||
|
||
if (hasDialogOpen.value) {
|
||
isPulling.value = false
|
||
pullDistance.value = 0
|
||
return
|
||
}
|
||
|
||
const touch = event.touches[0]
|
||
const deltaY = touch.clientY - startY.value
|
||
|
||
if (isPulling.value) {
|
||
if (deltaY > -config.TOLERANCE) {
|
||
pullDistance.value = Math.max(0, Math.min(deltaY * config.PULL_RESISTANCE, config.MAX_PULL_DISTANCE))
|
||
event.preventDefault()
|
||
} else {
|
||
isPulling.value = false
|
||
pullDistance.value = 0
|
||
}
|
||
} else {
|
||
if (deltaY > config.START_THRESHOLD) {
|
||
const currentScrollTop = window.scrollY || document.documentElement.scrollTop || 0
|
||
|
||
if (currentScrollTop <= 100 && initialScrollTop.value <= 100) {
|
||
isPulling.value = true
|
||
pullDistance.value = Math.min(deltaY * config.PULL_RESISTANCE, config.MAX_PULL_DISTANCE)
|
||
event.preventDefault()
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
const handleTouchEnd = () => {
|
||
if (!appMode.value || !display.mdAndDown.value || !options.enabled) return
|
||
|
||
// 检查是否可以使用下拉手势
|
||
if (options.canUsePullGesture && !options.canUsePullGesture()) return
|
||
|
||
// 重置弹窗检测标志
|
||
hasDialogOpen.value = false
|
||
lastDialogCheckTime.value = 0
|
||
|
||
if (isPulling.value && pullDistance.value >= config.TRIGGER_THRESHOLD) {
|
||
// 达到触发阈值,执行回调
|
||
options.onTrigger?.()
|
||
}
|
||
|
||
// 停止拖拽状态
|
||
isPulling.value = false
|
||
|
||
// 延迟重置其他状态
|
||
setTimeout(() => {
|
||
pullDistance.value = 0
|
||
startY.value = 0
|
||
}, 300)
|
||
}
|
||
|
||
// 生命周期管理
|
||
let eventsAdded = false
|
||
|
||
const addEventListeners = () => {
|
||
if (!eventsAdded && appMode.value) {
|
||
document.addEventListener('touchstart', handleTouchStart, { passive: false })
|
||
document.addEventListener('touchmove', handleTouchMove, { passive: false })
|
||
document.addEventListener('touchend', handleTouchEnd, { passive: true })
|
||
eventsAdded = true
|
||
}
|
||
}
|
||
|
||
const removeEventListeners = () => {
|
||
if (eventsAdded) {
|
||
document.removeEventListener('touchstart', handleTouchStart)
|
||
document.removeEventListener('touchmove', handleTouchMove)
|
||
document.removeEventListener('touchend', handleTouchEnd)
|
||
eventsAdded = false
|
||
}
|
||
}
|
||
|
||
// PWA状态确定后,一次性决定是否添加事件监听器
|
||
onMounted(() => {
|
||
// 等待PWA检测完成后添加事件监听器
|
||
const stopWatcher = watch(
|
||
appMode,
|
||
newValue => {
|
||
if (newValue) {
|
||
addEventListeners()
|
||
// PWA状态确定后停止监听
|
||
stopWatcher()
|
||
}
|
||
},
|
||
{ immediate: true },
|
||
)
|
||
})
|
||
|
||
onBeforeUnmount(() => {
|
||
removeEventListeners()
|
||
})
|
||
|
||
return {
|
||
// 状态
|
||
isPulling: readonly(isPulling),
|
||
pullDistance: readonly(pullDistance),
|
||
|
||
// 计算属性
|
||
contentTransform,
|
||
contentTransition,
|
||
showPullIndicator,
|
||
indicatorRotation,
|
||
indicatorOpacity,
|
||
indicatorTransform,
|
||
|
||
// 配置
|
||
config,
|
||
|
||
// 工具函数
|
||
hasOpenDialog,
|
||
}
|
||
}
|