fix(web): 移动端进度条可拖动、歌曲行单击即播

移动端两个交互在触屏上是死的:

1) 迷你播放器的进度条只是展示,整行的 click 又被绑成跳转歌词页。现在这条
   进度条用 Pointer Events(pointerdown/move/up/cancel + setPointerCapture)
   支持点按和拖动 seek,一套代码同时服务触摸、手写笔和鼠标,手指滑出细条也
   不会中断。可视轨道仍是 2px,但命中区域扩到 12px 并向下伸进播放器自身的
   8px 内边距——传输按钮高 32px、在 42px 内容区里居中,上沿在 13px,正好错开。

   拖动时渲染值切到手指位置,让 60fps 的 rAF 时钟别和手指抢(与音量条 #111
   同源问题);本地覆盖在 seek 请求 resolve 之后才释放,避免先跳回旧位置再
   跳到新位置。松手后 400ms 内的 click 被整行吞掉,否则 seek 完会被顺带导航
   到歌词页。没有 transport 权限或时长未知时整条退回纯展示,并把 touch-action
   还给页面,不会白吃掉滚动手势。

2) SongCard 和队列抽屉都用 @dblclick 触发播放,而 dblclick 是鼠标专属事件,
   触屏永远不会触发。现在改为按事件判断:click 在现代浏览器里是 PointerEvent,
   pointerType 为 touch/pen 时单击播放,鼠标单击行为完全不变(双击仍然播放)。
   用按事件判断而不是 matchMedia('(pointer: coarse)'),是因为后者只反映主指针,
   在带触摸屏的笔记本上会判断错。选 click 而非 pointerup 也是有意的:浏览器
   本就会抑制滑动手势末尾的 click,滑动列表时不会误触发播放。

   队列行的移除按钮原先没有 @click.stop,加了行级 click 后会「点一下播放顺手
   删掉」,一并补上,并按 SongCard 已有的约定在 coarse 指针下常显该按钮。

Closes #143

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
saopig1andClaude Opus 5 committed 2026-08-09 15:07:33 +08:00
1 parent 55695c2d1c
commit f777d892db
3 files changed
+294 -14

No files matched your search

+235 -11
View File
@@ -9,9 +9,20 @@
<Queue class="mobile-queue" :open="mobileQueueOpen" @close="mobileQueueOpen = false" /> <Queue class="mobile-queue" :open="mobileQueueOpen" @close="mobileQueueOpen = false" />
<!-- Mobile mini player --> <!-- Mobile mini player -->
<div v-if="currentSong" class="m-player" @click="router.push('/lyrics')"> <div v-if="currentSong" class="m-player" @click="onPlayerRowClick">
<div class="m-player-progress"> <div
<div class="m-player-progress-fill" :style="{ width: mobileProgressPct + '%' }" /> ref="seekBarRef"
class="m-player-progress"
:class="{ 'no-seek': !canSeek, dragging: seeking }"
@pointerdown="onSeekDown"
@pointermove="onSeekMove"
@pointerup="onSeekUp"
@pointercancel="onSeekCancel"
>
<div class="m-player-progress-track">
<div class="m-player-progress-fill" :style="{ width: seekBarPct + '%' }" />
<div class="m-player-progress-thumb" :style="{ left: seekBarPct + '%' }" />
</div>
</div> </div>
<CoverArt :url="currentSong.coverUrl" :size="40" :radius="8" /> <CoverArt :url="currentSong.coverUrl" :size="40" :radius="8" />
<div class="m-player-info"> <div class="m-player-info">
@@ -79,7 +90,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from 'vue'; import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router'; import { useRoute, useRouter } from 'vue-router';
import { Icon } from '@iconify/vue'; import { Icon } from '@iconify/vue';
import { usePlayerStore } from './stores/player.js'; import { usePlayerStore } from './stores/player.js';
@@ -132,15 +143,174 @@ let syncTimer: ReturnType<typeof setInterval> | null = null;
let mobileRaf: number | null = null; let mobileRaf: number | null = null;
function updateMobileProgress() { function updateMobileProgress() {
const duration = currentSong.value?.duration ?? 0; // While the finger owns the bar, the clock must keep its hands off it — see
// liveElapsed() recomputes each frame; the cached `elapsed` getter would // seekBarPct below. Skipping the write (rather than letting it be overridden)
// leave the mobile bar frozen between server pushes (#107). // also avoids 60 pointless reactive re-renders per second mid-drag.
mobileProgressPct.value = duration > 0 if (!seeking.value) {
? Math.min((playerStore.liveElapsed() / duration) * 100, 100) const duration = currentSong.value?.duration ?? 0;
: 0; // liveElapsed() recomputes each frame; the cached `elapsed` getter would
// leave the mobile bar frozen between server pushes (#107).
mobileProgressPct.value = duration > 0
? Math.min((playerStore.liveElapsed() / duration) * 100, 100)
: 0;
}
mobileRaf = requestAnimationFrame(updateMobileProgress); mobileRaf = requestAnimationFrame(updateMobileProgress);
} }
// --- Mini-player seek (#143) -------------------------------------------------
// The mobile progress bar used to be display-only. It now supports tap-to-seek
// and drag-to-seek via Pointer Events (one code path for touch, pen and mouse —
// no mouse/touch handler pairs) with setPointerCapture, so the drag survives the
// finger sliding off the 12px strip.
//
// Decoupling, exactly the reasoning of composables/useDecoupledSlider.ts (#111):
// updateMobileProgress() rewrites the rendered percentage every animation frame
// from the *server* clock, which is still the pre-seek position while the user
// drags. Binding the bar straight to it would snap the fill back under the
// finger ~60 times a second. So the rendered value is a computed that switches
// its source: the finger while `seeking`, the clock otherwise.
const seekBarRef = ref<HTMLElement | null>(null);
const seeking = ref(false);
const seekPct = ref(0);
let seekPointerId: number | null = null;
// The song the gesture started on. currentSong can advance mid-drag (the track
// ends), and the ratio the finger picked means nothing against a different
// song's duration.
let seekSongId: string | null = null;
// Bumped per gesture so a slow seek POST can't clear the override belonging to a
// newer drag that started while it was still in flight.
let seekGeneration = 0;
// Timestamp of the last seek gesture end, used to swallow the trailing click
// (see onPlayerRowClick).
let seekEndedAt = 0;
const seekBarPct = computed(() => (seeking.value ? seekPct.value : mobileProgressPct.value));
/** Pointer x → 0..1 along the strip, or null when the element isn't measurable. */
function seekRatio(e: PointerEvent): number | null {
const el = seekBarRef.value;
if (!el) return null;
const rect = el.getBoundingClientRect();
if (rect.width <= 0) return null;
return Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
}
/** Duration guard: live streams report 0/undefined and ratio*0 would seek to 0,
* while a missing duration would produce NaN — which the API rejects with 400. */
function seekableDuration(): number {
const duration = currentSong.value?.duration ?? 0;
return Number.isFinite(duration) && duration > 0 ? duration : 0;
}
// Drives the `no-seek` class as well as the gesture guard, so a bar that cannot
// be seeked also gives `touch-action` back to the page — otherwise the strip
// would be a 12px band that neither seeks nor scrolls.
const canSeek = computed(() => canTransport.value && seekableDuration() > 0);
function endSeekGesture() {
const el = seekBarRef.value;
if (el && seekPointerId !== null && el.hasPointerCapture?.(seekPointerId)) {
el.releasePointerCapture(seekPointerId);
}
seekPointerId = null;
seekEndedAt = Date.now();
}
function onSeekDown(e: PointerEvent) {
// Seeking is gated on transport, like the desktop player's `no-seek` state:
// without it the bar stays purely visual and taps fall through to the row.
if (!canSeek.value) return;
// One gesture at a time: a second finger landing on the strip would otherwise
// steal seekPointerId, leaving the first pointer captured forever and
// committing whichever finger happened to lift first.
if (seekPointerId !== null) return;
const ratio = seekRatio(e);
if (ratio === null) return;
// Never let the row's router.push('/lyrics') fire while the user is seeking.
e.stopPropagation();
e.preventDefault(); // suppress text selection / compat mouse events during the drag
seekPointerId = e.pointerId;
seekSongId = currentSong.value?.id ?? null;
seekGeneration += 1;
seekBarRef.value?.setPointerCapture?.(e.pointerId);
seeking.value = true;
seekPct.value = ratio * 100;
}
function onSeekMove(e: PointerEvent) {
if (!seeking.value || e.pointerId !== seekPointerId) return;
const ratio = seekRatio(e);
if (ratio === null) return;
e.stopPropagation();
seekPct.value = ratio * 100;
}
async function onSeekUp(e: PointerEvent) {
if (!seeking.value || e.pointerId !== seekPointerId) return;
e.stopPropagation();
// A tap never moves, so pointerup is also the commit point for tap-to-seek.
const ratio = seekRatio(e) ?? seekPct.value / 100;
seekPct.value = ratio * 100;
const duration = seekableDuration();
const generation = seekGeneration;
// If the track advanced while the finger was down, the ratio belongs to a
// song that is no longer playing — drop the seek rather than applying it to
// whatever started next.
const sameSong = currentSong.value?.id === seekSongId;
endSeekGesture(); // must run synchronously, before the awaited POST
try {
if (duration > 0 && sameSong) await playerStore.seek(ratio * duration);
} catch {
// Seek rejected (403/400/offline) — fall back to the server clock below.
} finally {
// Release the local override only once seek() has resolved. store.seek()
// moves its timing anchor to the requested position in the same tick, so
// liveElapsed() already reports the new spot and the bar simply carries on
// from where the finger left it. Releasing at pointerup instead would show
// the *old* position for one round-trip and then jump a second time.
// (_syncAfterAction re-polls 500ms later, but that only nudges the bar by
// the network delta — not worth freezing the clock for.)
if (generation === seekGeneration) seeking.value = false;
}
}
function onSeekCancel(e: PointerEvent) {
if (e.pointerId !== seekPointerId) return;
// Gesture stolen (system gesture, call, …): abandon without seeking and hand
// the bar straight back to the clock.
endSeekGesture();
seeking.value = false;
}
// The whole mini player lives inside `v-if="currentSong"`, so when playback
// stops mid-drag the strip is destroyed and no pointerup/pointercancel can ever
// reach it — element removal is not a pointercancel trigger. Without this the
// `seeking` override would stay true and the progress bar would sit frozen for
// the rest of the session. Bumping the generation also neuters the finally of
// any seek still in flight.
watch(currentSong, () => {
if (!seeking.value) return;
seekGeneration += 1;
seekPointerId = null;
seekSongId = null;
seeking.value = false;
});
function onPlayerRowClick() {
// Both a tap and a drag on the strip emit a trailing `click`, which would
// otherwise navigate to /lyrics the moment the user finishes seeking. A
// `@click.stop` on the strip is not enough: after a drag the click's target is
// the nearest common ancestor of the pointerdown/pointerup hit-tests, i.e.
// `.m-player` itself once the finger has left the 12px strip. So the row
// swallows any click arriving right after a seek gesture. A timestamp rather
// than a flag, so a gesture that produces no click at all (preventDefault,
// pointercancel) can't leave the row permanently unclickable — and so an inert
// strip (no transport permission, unknown duration) still falls through here
// and navigates, exactly as it did before.
if (Date.now() - seekEndedAt < 400) return;
router.push('/lyrics');
}
function toggleMobileVolume() { function toggleMobileVolume() {
mobileVolumeOpen.value = !mobileVolumeOpen.value; mobileVolumeOpen.value = !mobileVolumeOpen.value;
if (mobileVolumeOpen.value) mobileQueueOpen.value = false; if (mobileVolumeOpen.value) mobileQueueOpen.value = false;
@@ -242,15 +412,69 @@ onUnmounted(() => {
top: 0; top: 0;
left: 10px; left: 10px;
right: 10px; right: 10px;
// The visible track stays 2px, but 2px is not a touch target (#143), so the
// hit area is 12px and grows DOWNWARD into the mini player's own 8px top
// padding. It must not reach the transport buttons: they are 32px tall and
// centred in the 58px row's 42px content box, i.e. their top edge sits at
// 8 + (42 - 32) / 2 = 13px. 12px clears them by 1px. Growing upward is not an
// option — that is outside the player's rounded top edge.
height: 12px;
// Without this the browser claims the gesture for page scrolling partway
// through the drag and the pointermove stream stops.
touch-action: none;
cursor: pointer;
user-select: none;
-webkit-user-select: none;
// No transport permission → purely decorative (mirrors Player.vue's .no-seek).
// Handing touch-action back matters: an inert strip must not eat gestures.
&.no-seek {
touch-action: auto;
}
}
.m-player-progress-track {
position: relative;
// Nudged down inside the 12px hit area so the drag thumb, which is centred on
// the track, stays within the card instead of poking out above its top edge.
// The 8px thumb's box is (3 + 1 - 4) = 0 to 8, i.e. exactly flush with the
// card. (The container is absolutely positioned, so it forms a BFC and this
// margin cannot collapse through it.)
margin-top: 3px;
height: 2px; height: 2px;
border-radius: 1px;
background: var(--border-color);
} }
.m-player-progress-fill { .m-player-progress-fill {
height: 2px; position: absolute;
top: 0;
left: 0;
height: 100%;
background: var(--color-primary); background: var(--color-primary);
border-radius: 1px; border-radius: 1px;
} }
.m-player-progress-thumb {
position: absolute;
top: 1px;
width: 8px;
height: 8px;
margin-top: -4px;
margin-left: -4px;
background: var(--color-primary);
border-radius: var(--radius-full);
opacity: 0;
transform: scale(0);
transition: opacity var(--transition-fast), transform var(--transition-fast);
pointer-events: none;
}
.m-player-progress.dragging .m-player-progress-thumb {
opacity: 1;
transform: scale(1);
}
.m-player-info { .m-player-info {
flex: 1; flex: 1;
min-width: 0; min-width: 0;
+28 -1
View File
@@ -26,6 +26,7 @@
:key="`${song.id}-${i}`" :key="`${song.id}-${i}`"
class="queue-item" class="queue-item"
:class="{ active: store.currentSong?.id === song.id }" :class="{ active: store.currentSong?.id === song.id }"
@click="onRowClick($event, i)"
@dblclick="playAtIndex(i)" @dblclick="playAtIndex(i)"
> >
<CoverArt :url="song.coverUrl" :size="32" :radius="4" /> <CoverArt :url="song.coverUrl" :size="32" :radius="4" />
@@ -33,7 +34,7 @@
<div class="queue-song-name">{{ song.name }}</div> <div class="queue-song-name">{{ song.name }}</div>
<div class="queue-song-artist">{{ song.artist }}</div> <div class="queue-song-artist">{{ song.artist }}</div>
</div> </div>
<button v-if="can('player.queue') || guestCan('removeClear')" class="remove-btn" @click="removeSong(i)" title="移除"> <button v-if="can('player.queue') || guestCan('removeClear')" class="remove-btn" @click.stop="removeSong(i)" title="移除">
<Icon icon="mdi:close" /> <Icon icon="mdi:close" />
</button> </button>
</div> </div>
@@ -72,6 +73,21 @@ async function playAtIndex(index: number) {
await store.fetchQueue(); await store.fetchQueue();
} }
// Touch has no `dblclick`, so double-click-to-play was dead in the mobile queue
// drawer (#143). Same per-event touch rule as SongCard.vue — see the full
// rationale in the `isTouchClick` comment there. Permission gating stays in
// playAtIndex(); the remove button uses @click.stop so it can't double-fire.
function isTouchClick(e: MouseEvent): boolean {
const pointerType = (e as PointerEvent).pointerType;
if (pointerType) return pointerType === 'touch' || pointerType === 'pen';
return window.matchMedia?.('(pointer: coarse)').matches ?? false;
}
function onRowClick(e: MouseEvent, index: number) {
if (!isTouchClick(e)) return;
void playAtIndex(index);
}
async function removeSong(index: number) { async function removeSong(index: number) {
if (!store.activeBotId) return; if (!store.activeBotId) return;
try { try {
@@ -205,4 +221,15 @@ async function clearAndStop() {
color: var(--text-tertiary); color: var(--text-tertiary);
&:hover { color: var(--text-primary); } &:hover { color: var(--text-primary); }
} }
// Touch devices have no :hover, so the parent-hover-reveals-the-button pattern
// leaves an *invisible but still tappable* remove button on the right edge of
// every row. Now that a single tap on the row plays (#143), that invisible
// target reads as "I tapped to play and it deleted the song" — show it, same as
// .song-actions in SongCard.vue.
@media (pointer: coarse) {
.remove-btn {
opacity: 1;
}
}
</style> </style>
+31 -2
View File
@@ -1,5 +1,5 @@
<template> <template>
<div class="song-card" :class="{ active }" @dblclick="showPlay && $emit('play')"> <div class="song-card" :class="{ active }" @click="onRowClick" @dblclick="showPlay && $emit('play')">
<div class="song-index">{{ index }}</div> <div class="song-index">{{ index }}</div>
<CoverArt :url="song.coverUrl" :size="36" :radius="6" /> <CoverArt :url="song.coverUrl" :size="36" :radius="6" />
<div class="song-info"> <div class="song-info">
@@ -51,12 +51,41 @@ const showPlay = computed(() => can('player.control') || guestCan('playNow'));
const showPlayNext = computed(() => can('player.control') || guestCan('playNext')); const showPlayNext = computed(() => can('player.control') || guestCan('playNext'));
const showAdd = computed(() => can('player.queue') || guestCan('addToQueue')); const showAdd = computed(() => can('player.queue') || guestCan('addToQueue'));
defineEmits<{ const emit = defineEmits<{
play: []; play: [];
playNext: []; playNext: [];
add: []; add: [];
}>(); }>();
/**
* Was this click made with a finger/stylus rather than a mouse? (#143)
*
* `dblclick` is mouse-only and never fires on touch, so double-tap-to-play was
* simply dead on phones. A single *tap* plays instead, while a single mouse
* click must keep doing nothing — otherwise desktop behaviour changes and the
* surviving dblclick would fire play twice.
*
* The check is per-EVENT, not per-device: a `click` is a PointerEvent in modern
* browsers, so pointerType describes how *this* click was made. A global
* matchMedia('(pointer: coarse)') check reports only the *primary* pointer and
* is therefore wrong on hybrid laptops (touchscreen + trackpad); it is used only
* as a fallback for browsers that give us no pointerType.
*/
function isTouchClick(e: MouseEvent): boolean {
const pointerType = (e as PointerEvent).pointerType;
if (pointerType) return pointerType === 'touch' || pointerType === 'pen';
return window.matchMedia?.('(pointer: coarse)').matches ?? false;
}
// Listening on `click` rather than `pointerup` on purpose: the browser already
// suppresses the click that ended a scroll gesture, so a tap that was really the
// start of a flick can't hijack playback for everyone in the channel.
// (The action buttons stop propagation, so they never double-fire this.)
function onRowClick(e: MouseEvent) {
if (!showPlay.value || !isTouchClick(e)) return;
emit('play');
}
function formatDuration(seconds: number): string { function formatDuration(seconds: number): string {
const m = Math.floor(seconds / 60); const m = Math.floor(seconds / 60);
const s = seconds % 60; const s = seconds % 60;