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

+28 -1
View File
@@ -26,6 +26,7 @@
:key="`${song.id}-${i}`"
class="queue-item"
:class="{ active: store.currentSong?.id === song.id }"
@click="onRowClick($event, i)"
@dblclick="playAtIndex(i)"
>
<CoverArt :url="song.coverUrl" :size="32" :radius="4" />
@@ -33,7 +34,7 @@
<div class="queue-song-name">{{ song.name }}</div>
<div class="queue-song-artist">{{ song.artist }}</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" />
</button>
</div>
@@ -72,6 +73,21 @@ async function playAtIndex(index: number) {
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) {
if (!store.activeBotId) return;
try {
@@ -205,4 +221,15 @@ async function clearAndStop() {
color: var(--text-tertiary);
&: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>
+31 -2
View File
@@ -1,5 +1,5 @@
<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>
<CoverArt :url="song.coverUrl" :size="36" :radius="6" />
<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 showAdd = computed(() => can('player.queue') || guestCan('addToQueue'));
defineEmits<{
const emit = defineEmits<{
play: [];
playNext: [];
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 {
const m = Math.floor(seconds / 60);
const s = seconds % 60;