mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-09-04 23:18:44 +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_MAX_BUBBLES = 4
|
||||||
const FAB_DEFAULT_RIGHT_OFFSET = 18
|
const FAB_DEFAULT_RIGHT_OFFSET = 18
|
||||||
const FAB_DEFAULT_VERTICAL_RATIO = 2 / 3
|
const FAB_DEFAULT_VERTICAL_RATIO = 2 / 3
|
||||||
|
const FAB_MOBILE_VIEWPORT_WIDTH = 600
|
||||||
const FAB_BUBBLE_GAP = 12
|
const FAB_BUBBLE_GAP = 12
|
||||||
|
const FAB_MOBILE_BUBBLE_GAP = 6
|
||||||
const FAB_BUBBLE_SAFE_MARGIN = 12
|
const FAB_BUBBLE_SAFE_MARGIN = 12
|
||||||
const FAB_BUBBLE_ARROW_MARGIN = 28
|
const FAB_BUBBLE_ARROW_MARGIN = 28
|
||||||
const FAB_BUBBLE_EDGE_ARROW_OFFSET = 38
|
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() {
|
function getOpenFabSize() {
|
||||||
const viewport = getViewportSize()
|
const viewport = getViewportSize()
|
||||||
const isMobile = viewport.width <= 600
|
const isMobile = isMobileFabViewport()
|
||||||
|
|
||||||
return {
|
return {
|
||||||
height: isMobile ? 106 : 115,
|
height: isMobile ? 106 : 115,
|
||||||
@@ -210,9 +222,8 @@ function getOpenFabSize() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getFallbackFabInteractiveBounds(): FabInteractiveBounds {
|
function getFallbackFabInteractiveBounds(): FabInteractiveBounds {
|
||||||
const viewport = getViewportSize()
|
|
||||||
const rootSize = getOpenFabSize()
|
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 {
|
return {
|
||||||
height: triggerSize.height,
|
height: triggerSize.height,
|
||||||
@@ -355,6 +366,7 @@ function getBubbleCandidatePenalty(
|
|||||||
bubbleSize: { height: number; width: number },
|
bubbleSize: { height: number; width: number },
|
||||||
anchorRect: DOMRect,
|
anchorRect: DOMRect,
|
||||||
viewport: { height: number; width: number },
|
viewport: { height: number; width: number },
|
||||||
|
bubbleGap: number,
|
||||||
) {
|
) {
|
||||||
const x = clampBubbleAxis(candidate.idealX, bubbleSize.width, viewport.width)
|
const x = clampBubbleAxis(candidate.idealX, bubbleSize.width, viewport.width)
|
||||||
const y = clampBubbleAxis(candidate.idealY, bubbleSize.height, viewport.height)
|
const y = clampBubbleAxis(candidate.idealY, bubbleSize.height, viewport.height)
|
||||||
@@ -366,8 +378,8 @@ function getBubbleCandidatePenalty(
|
|||||||
}[candidate.placement]
|
}[candidate.placement]
|
||||||
const primaryRequired =
|
const primaryRequired =
|
||||||
candidate.placement === 'left' || candidate.placement === 'right'
|
candidate.placement === 'left' || candidate.placement === 'right'
|
||||||
? bubbleSize.width + FAB_BUBBLE_GAP
|
? bubbleSize.width + bubbleGap
|
||||||
: bubbleSize.height + FAB_BUBBLE_GAP
|
: bubbleSize.height + bubbleGap
|
||||||
const fitPenalty = Math.max(0, primaryRequired - primaryAvailable) * 8
|
const fitPenalty = Math.max(0, primaryRequired - primaryAvailable) * 8
|
||||||
const alignmentPenalty = Math.abs(x - candidate.idealX) + Math.abs(y - candidate.idealY)
|
const alignmentPenalty = Math.abs(x - candidate.idealX) + Math.abs(y - candidate.idealY)
|
||||||
|
|
||||||
@@ -386,36 +398,39 @@ function calculateFabBubbleLayout(): FabBubbleLayout | null {
|
|||||||
|
|
||||||
const viewport = getViewportSize()
|
const viewport = getViewportSize()
|
||||||
const bubbleSize = getFabBubbleSize()
|
const bubbleSize = getFabBubbleSize()
|
||||||
|
const bubbleGap = getFabBubbleGap()
|
||||||
const anchorCenterX = anchorRect.left + anchorRect.width / 2
|
const anchorCenterX = anchorRect.left + anchorRect.width / 2
|
||||||
const anchorCenterY = anchorRect.top + anchorRect.height / 2
|
const anchorCenterY = anchorRect.top + anchorRect.height / 2
|
||||||
|
const mobileSidePlacement = anchorCenterX >= viewport.width / 2 ? 'left' : 'right'
|
||||||
|
const preferMobileSideBubble = isMobileFabViewport()
|
||||||
const candidates: FabBubbleCandidate[] = [
|
const candidates: FabBubbleCandidate[] = [
|
||||||
{
|
{
|
||||||
idealX: anchorCenterX - bubbleSize.width / 2,
|
idealX: anchorCenterX - bubbleSize.width / 2,
|
||||||
idealY: anchorRect.top - bubbleSize.height - FAB_BUBBLE_GAP,
|
idealY: anchorRect.top - bubbleSize.height - bubbleGap,
|
||||||
placement: 'top',
|
placement: 'top',
|
||||||
weight: 0,
|
weight: preferMobileSideBubble ? 18 : 0,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
idealX: anchorCenterX - bubbleSize.width / 2,
|
idealX: anchorCenterX - bubbleSize.width / 2,
|
||||||
idealY: anchorRect.bottom + FAB_BUBBLE_GAP,
|
idealY: anchorRect.bottom + bubbleGap,
|
||||||
placement: 'bottom',
|
placement: 'bottom',
|
||||||
weight: 4,
|
weight: preferMobileSideBubble ? 22 : 4,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
idealX: anchorRect.right + FAB_BUBBLE_GAP,
|
idealX: anchorRect.right + bubbleGap,
|
||||||
idealY: anchorCenterY - bubbleSize.height / 2,
|
idealY: anchorCenterY - bubbleSize.height / 2,
|
||||||
placement: 'right',
|
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,
|
idealY: anchorCenterY - bubbleSize.height / 2,
|
||||||
placement: 'left',
|
placement: 'left',
|
||||||
weight: 8,
|
weight: preferMobileSideBubble && mobileSidePlacement === 'left' ? -4 : 8,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
const bestCandidate = candidates
|
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]
|
.sort((a, b) => a.score - b.score)[0]
|
||||||
|
|
||||||
if (!bestCandidate) return null
|
if (!bestCandidate) return null
|
||||||
|
|||||||
@@ -93,6 +93,14 @@ const selectedSites = ref<number[]>([])
|
|||||||
// 搜索菜单显示状态
|
// 搜索菜单显示状态
|
||||||
const searchMenuShow = ref(false)
|
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() {
|
function openSearchSiteDialog() {
|
||||||
openSharedDialog(
|
openSharedDialog(
|
||||||
@@ -250,6 +258,8 @@ async function handleSubscribe() {
|
|||||||
// 打开详情页
|
// 打开详情页
|
||||||
function goMediaDetail(isHovering = false) {
|
function goMediaDetail(isHovering = false) {
|
||||||
if (isHovering) {
|
if (isHovering) {
|
||||||
|
resetMediaCardDetailState()
|
||||||
|
|
||||||
if (props.media?.collection_id) {
|
if (props.media?.collection_id) {
|
||||||
// 跳转到合集列表
|
// 跳转到合集列表
|
||||||
router.push({
|
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() {
|
async function clickSearch() {
|
||||||
if (allSites.value?.length == 0) {
|
if (allSites.value?.length == 0) {
|
||||||
@@ -373,6 +409,7 @@ watch(isSubscribed, subscribed => {
|
|||||||
watch(
|
watch(
|
||||||
() => props.media,
|
() => props.media,
|
||||||
() => {
|
() => {
|
||||||
|
resetMediaCardDetailState()
|
||||||
subscribedSeasons.value = []
|
subscribedSeasons.value = []
|
||||||
subscribedSeasonModes.value = {}
|
subscribedSeasonModes.value = {}
|
||||||
subscribedSeasonsLoaded.value = false
|
subscribedSeasonsLoaded.value = false
|
||||||
@@ -383,7 +420,11 @@ onMounted(() => {
|
|||||||
setupIntersectionObserver()
|
setupIntersectionObserver()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
onActivated(resetMediaCardDetailState)
|
||||||
|
onDeactivated(resetMediaCardDetailState)
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
|
resetMediaCardDetailState()
|
||||||
observer.value?.disconnect()
|
observer.value?.disconnect()
|
||||||
observer.value = null
|
observer.value = null
|
||||||
})
|
})
|
||||||
@@ -404,10 +445,10 @@ onBeforeUnmount(() => {
|
|||||||
:width="props.width"
|
:width="props.width"
|
||||||
class="app-hover-lift-card outline-none ring-gray-500 media-card"
|
class="app-hover-lift-card outline-none ring-gray-500 media-card"
|
||||||
:class="{
|
:class="{
|
||||||
'app-hover-lift-card--hovering': hover.isHovering,
|
'app-hover-lift-card--hovering': isMediaCardActive(hover.isHovering),
|
||||||
'ring-1': isImageLoaded,
|
'ring-1': isImageLoaded,
|
||||||
}"
|
}"
|
||||||
@click.stop="goMediaDetail(hover.isHovering ?? false)"
|
@click.stop="handleMediaCardClick(hover.isHovering)"
|
||||||
>
|
>
|
||||||
<VImg
|
<VImg
|
||||||
aspect-ratio="2/3"
|
aspect-ratio="2/3"
|
||||||
@@ -426,7 +467,7 @@ onBeforeUnmount(() => {
|
|||||||
|
|
||||||
<!-- 详情 -->
|
<!-- 详情 -->
|
||||||
<VCardText
|
<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"
|
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%)"
|
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) }}
|
{{ getMediaTypeText(props.media?.type) }}
|
||||||
</VChip>
|
</VChip>
|
||||||
<!-- 本地存在标识 -->
|
<!-- 本地存在标识 -->
|
||||||
<ExistIcon v-if="isExists && !hover.isHovering" />
|
<ExistIcon v-if="isExists && !isMediaCardActive(hover.isHovering)" />
|
||||||
<!-- 评分角标 -->
|
<!-- 评分角标 -->
|
||||||
<VChip
|
<VChip
|
||||||
v-if="isImageLoaded && props.media?.vote_average && !(isExists && !hover.isHovering)"
|
v-if="isImageLoaded && props.media?.vote_average && !(isExists && !isMediaCardActive(hover.isHovering))"
|
||||||
variant="elevated"
|
variant="elevated"
|
||||||
size="small"
|
size="small"
|
||||||
:class="getChipColor('rating')"
|
:class="getChipColor('rating')"
|
||||||
@@ -491,7 +532,7 @@ onBeforeUnmount(() => {
|
|||||||
density="compact"
|
density="compact"
|
||||||
class="absolute bottom-1 right-1"
|
class="absolute bottom-1 right-1"
|
||||||
tile
|
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" />
|
<VImg cover :src="sourceIconDict[props.media?.source]" class="shadow-lg" />
|
||||||
</VAvatar>
|
</VAvatar>
|
||||||
|
|||||||
Reference in New Issue
Block a user