mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-07-06 06:51:29 +08:00
fix: improve mobile interaction handling for media cards
This commit is contained in:
@@ -53,7 +53,9 @@ const FAB_TOAST_BUBBLE_DURATION = 4500
|
||||
const FAB_MAX_BUBBLES = 4
|
||||
const FAB_DEFAULT_RIGHT_OFFSET = 18
|
||||
const FAB_DEFAULT_VERTICAL_RATIO = 2 / 3
|
||||
const FAB_MOBILE_VIEWPORT_WIDTH = 600
|
||||
const FAB_BUBBLE_GAP = 12
|
||||
const FAB_MOBILE_BUBBLE_GAP = 6
|
||||
const FAB_BUBBLE_SAFE_MARGIN = 12
|
||||
const FAB_BUBBLE_ARROW_MARGIN = 28
|
||||
const FAB_BUBBLE_EDGE_ARROW_OFFSET = 38
|
||||
@@ -199,9 +201,19 @@ function getViewportSize() {
|
||||
}
|
||||
}
|
||||
|
||||
// 判断当前 FAB 是否处于移动端布局,用于同步 JS 布局和 CSS 媒体查询。
|
||||
function isMobileFabViewport() {
|
||||
return getViewportSize().width <= FAB_MOBILE_VIEWPORT_WIDTH
|
||||
}
|
||||
|
||||
// 移动端气泡与机器人距离更短,避免 iOS 触摸视图里气泡显得过远。
|
||||
function getFabBubbleGap() {
|
||||
return isMobileFabViewport() ? FAB_MOBILE_BUBBLE_GAP : FAB_BUBBLE_GAP
|
||||
}
|
||||
|
||||
function getOpenFabSize() {
|
||||
const viewport = getViewportSize()
|
||||
const isMobile = viewport.width <= 600
|
||||
const isMobile = isMobileFabViewport()
|
||||
|
||||
return {
|
||||
height: isMobile ? 106 : 115,
|
||||
@@ -210,9 +222,8 @@ function getOpenFabSize() {
|
||||
}
|
||||
|
||||
function getFallbackFabInteractiveBounds(): FabInteractiveBounds {
|
||||
const viewport = getViewportSize()
|
||||
const rootSize = getOpenFabSize()
|
||||
const triggerSize = viewport.width <= 600 ? { height: 77, width: 80 } : { height: 82, width: 86 }
|
||||
const triggerSize = isMobileFabViewport() ? { height: 77, width: 80 } : { height: 82, width: 86 }
|
||||
|
||||
return {
|
||||
height: triggerSize.height,
|
||||
@@ -355,6 +366,7 @@ function getBubbleCandidatePenalty(
|
||||
bubbleSize: { height: number; width: number },
|
||||
anchorRect: DOMRect,
|
||||
viewport: { height: number; width: number },
|
||||
bubbleGap: number,
|
||||
) {
|
||||
const x = clampBubbleAxis(candidate.idealX, bubbleSize.width, viewport.width)
|
||||
const y = clampBubbleAxis(candidate.idealY, bubbleSize.height, viewport.height)
|
||||
@@ -366,8 +378,8 @@ function getBubbleCandidatePenalty(
|
||||
}[candidate.placement]
|
||||
const primaryRequired =
|
||||
candidate.placement === 'left' || candidate.placement === 'right'
|
||||
? bubbleSize.width + FAB_BUBBLE_GAP
|
||||
: bubbleSize.height + FAB_BUBBLE_GAP
|
||||
? bubbleSize.width + bubbleGap
|
||||
: bubbleSize.height + bubbleGap
|
||||
const fitPenalty = Math.max(0, primaryRequired - primaryAvailable) * 8
|
||||
const alignmentPenalty = Math.abs(x - candidate.idealX) + Math.abs(y - candidate.idealY)
|
||||
|
||||
@@ -386,36 +398,39 @@ function calculateFabBubbleLayout(): FabBubbleLayout | null {
|
||||
|
||||
const viewport = getViewportSize()
|
||||
const bubbleSize = getFabBubbleSize()
|
||||
const bubbleGap = getFabBubbleGap()
|
||||
const anchorCenterX = anchorRect.left + anchorRect.width / 2
|
||||
const anchorCenterY = anchorRect.top + anchorRect.height / 2
|
||||
const mobileSidePlacement = anchorCenterX >= viewport.width / 2 ? 'left' : 'right'
|
||||
const preferMobileSideBubble = isMobileFabViewport()
|
||||
const candidates: FabBubbleCandidate[] = [
|
||||
{
|
||||
idealX: anchorCenterX - bubbleSize.width / 2,
|
||||
idealY: anchorRect.top - bubbleSize.height - FAB_BUBBLE_GAP,
|
||||
idealY: anchorRect.top - bubbleSize.height - bubbleGap,
|
||||
placement: 'top',
|
||||
weight: 0,
|
||||
weight: preferMobileSideBubble ? 18 : 0,
|
||||
},
|
||||
{
|
||||
idealX: anchorCenterX - bubbleSize.width / 2,
|
||||
idealY: anchorRect.bottom + FAB_BUBBLE_GAP,
|
||||
idealY: anchorRect.bottom + bubbleGap,
|
||||
placement: 'bottom',
|
||||
weight: 4,
|
||||
weight: preferMobileSideBubble ? 22 : 4,
|
||||
},
|
||||
{
|
||||
idealX: anchorRect.right + FAB_BUBBLE_GAP,
|
||||
idealX: anchorRect.right + bubbleGap,
|
||||
idealY: anchorCenterY - bubbleSize.height / 2,
|
||||
placement: 'right',
|
||||
weight: 8,
|
||||
weight: preferMobileSideBubble && mobileSidePlacement === 'right' ? -4 : 8,
|
||||
},
|
||||
{
|
||||
idealX: anchorRect.left - bubbleSize.width - FAB_BUBBLE_GAP,
|
||||
idealX: anchorRect.left - bubbleSize.width - bubbleGap,
|
||||
idealY: anchorCenterY - bubbleSize.height / 2,
|
||||
placement: 'left',
|
||||
weight: 8,
|
||||
weight: preferMobileSideBubble && mobileSidePlacement === 'left' ? -4 : 8,
|
||||
},
|
||||
]
|
||||
const bestCandidate = candidates
|
||||
.map(candidate => getBubbleCandidatePenalty(candidate, bubbleSize, anchorRect, viewport))
|
||||
.map(candidate => getBubbleCandidatePenalty(candidate, bubbleSize, anchorRect, viewport, bubbleGap))
|
||||
.sort((a, b) => a.score - b.score)[0]
|
||||
|
||||
if (!bestCandidate) return null
|
||||
|
||||
@@ -93,6 +93,14 @@ const selectedSites = ref<number[]>([])
|
||||
// 搜索菜单显示状态
|
||||
const searchMenuShow = ref(false)
|
||||
|
||||
// 触摸设备没有稳定 hover,单独保存详情层的展开状态。
|
||||
const touchDetailVisible = ref(false)
|
||||
|
||||
// 粗指针设备使用点击展开详情,避免 iOS 返回后沿用 VHover 的触摸态。
|
||||
const isTouchLikePointer = ref(
|
||||
typeof window !== 'undefined' && Boolean(window.matchMedia?.('(hover: none), (pointer: coarse)').matches),
|
||||
)
|
||||
|
||||
// 打开站点选择弹窗,并把选择结果交回当前媒体卡片继续搜索。
|
||||
function openSearchSiteDialog() {
|
||||
openSharedDialog(
|
||||
@@ -250,6 +258,8 @@ async function handleSubscribe() {
|
||||
// 打开详情页
|
||||
function goMediaDetail(isHovering = false) {
|
||||
if (isHovering) {
|
||||
resetMediaCardDetailState()
|
||||
|
||||
if (props.media?.collection_id) {
|
||||
// 跳转到合集列表
|
||||
router.push({
|
||||
@@ -273,6 +283,32 @@ function goMediaDetail(isHovering = false) {
|
||||
}
|
||||
}
|
||||
|
||||
// 当前卡片是否进入可操作详情态,桌面使用 hover,触摸端使用显式点击态。
|
||||
function isMediaCardActive(isHovering: boolean | null | undefined) {
|
||||
return touchDetailVisible.value || (!isTouchLikePointer.value && Boolean(isHovering))
|
||||
}
|
||||
|
||||
// 当前卡片详情层是否显示,保留图片错误和站点选择时的强制显示。
|
||||
function isMediaCardDetailVisible(isHovering: boolean | null | undefined) {
|
||||
return isMediaCardActive(isHovering) || imageLoadError.value || searchMenuShow.value
|
||||
}
|
||||
|
||||
// 清理移动端详情层展开态,避免路由返回后继续显示上一次的详情层。
|
||||
function resetMediaCardDetailState() {
|
||||
touchDetailVisible.value = false
|
||||
}
|
||||
|
||||
// 处理媒体卡片点击:触摸端第一次点击展开,展开后再次点击进入详情。
|
||||
function handleMediaCardClick(isHovering: boolean | null | undefined) {
|
||||
if (isTouchLikePointer.value && !isMediaCardDetailVisible(isHovering)) {
|
||||
touchDetailVisible.value = true
|
||||
querySubscribedSeasons()
|
||||
return
|
||||
}
|
||||
|
||||
goMediaDetail(isMediaCardActive(isHovering) || isMediaCardDetailVisible(isHovering))
|
||||
}
|
||||
|
||||
// 点击搜索
|
||||
async function clickSearch() {
|
||||
if (allSites.value?.length == 0) {
|
||||
@@ -373,6 +409,7 @@ watch(isSubscribed, subscribed => {
|
||||
watch(
|
||||
() => props.media,
|
||||
() => {
|
||||
resetMediaCardDetailState()
|
||||
subscribedSeasons.value = []
|
||||
subscribedSeasonModes.value = {}
|
||||
subscribedSeasonsLoaded.value = false
|
||||
@@ -383,7 +420,11 @@ onMounted(() => {
|
||||
setupIntersectionObserver()
|
||||
})
|
||||
|
||||
onActivated(resetMediaCardDetailState)
|
||||
onDeactivated(resetMediaCardDetailState)
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
resetMediaCardDetailState()
|
||||
observer.value?.disconnect()
|
||||
observer.value = null
|
||||
})
|
||||
@@ -404,10 +445,10 @@ onBeforeUnmount(() => {
|
||||
:width="props.width"
|
||||
class="app-hover-lift-card outline-none ring-gray-500 media-card"
|
||||
:class="{
|
||||
'app-hover-lift-card--hovering': hover.isHovering,
|
||||
'app-hover-lift-card--hovering': isMediaCardActive(hover.isHovering),
|
||||
'ring-1': isImageLoaded,
|
||||
}"
|
||||
@click.stop="goMediaDetail(hover.isHovering ?? false)"
|
||||
@click.stop="handleMediaCardClick(hover.isHovering)"
|
||||
>
|
||||
<VImg
|
||||
aspect-ratio="2/3"
|
||||
@@ -426,7 +467,7 @@ onBeforeUnmount(() => {
|
||||
|
||||
<!-- 详情 -->
|
||||
<VCardText
|
||||
v-show="hover.isHovering || imageLoadError || searchMenuShow"
|
||||
v-show="isMediaCardDetailVisible(hover.isHovering)"
|
||||
class="w-full h-full flex flex-col flex-wrap justify-end align-left text-white absolute bottom-0 cursor-pointer pa-2"
|
||||
style="background: linear-gradient(rgba(45, 55, 72, 40%) 0%, rgba(45, 55, 72, 90%) 100%)"
|
||||
>
|
||||
@@ -473,10 +514,10 @@ onBeforeUnmount(() => {
|
||||
{{ getMediaTypeText(props.media?.type) }}
|
||||
</VChip>
|
||||
<!-- 本地存在标识 -->
|
||||
<ExistIcon v-if="isExists && !hover.isHovering" />
|
||||
<ExistIcon v-if="isExists && !isMediaCardActive(hover.isHovering)" />
|
||||
<!-- 评分角标 -->
|
||||
<VChip
|
||||
v-if="isImageLoaded && props.media?.vote_average && !(isExists && !hover.isHovering)"
|
||||
v-if="isImageLoaded && props.media?.vote_average && !(isExists && !isMediaCardActive(hover.isHovering))"
|
||||
variant="elevated"
|
||||
size="small"
|
||||
:class="getChipColor('rating')"
|
||||
@@ -491,7 +532,7 @@ onBeforeUnmount(() => {
|
||||
density="compact"
|
||||
class="absolute bottom-1 right-1"
|
||||
tile
|
||||
v-if="!hover.isHovering && isImageLoaded && props.media?.source && !imageLoadError"
|
||||
v-if="!isMediaCardActive(hover.isHovering) && isImageLoaded && props.media?.source && !imageLoadError"
|
||||
>
|
||||
<VImg cover :src="sourceIconDict[props.media?.source]" class="shadow-lg" />
|
||||
</VAvatar>
|
||||
|
||||
Reference in New Issue
Block a user