mirror of
https://github.com/ZHANGTIANYAO1/teamspeak-music-bot.git
synced 2026-10-01 20:42:50 +08:00
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:
1 parent
55695c2d1c
commit
f777d892db
3 files changed
+294
-14
No files matched your search
+235
-11
@@ -9,9 +9,20 @@
|
||||
<Queue class="mobile-queue" :open="mobileQueueOpen" @close="mobileQueueOpen = false" />
|
||||
|
||||
<!-- Mobile mini player -->
|
||||
<div v-if="currentSong" class="m-player" @click="router.push('/lyrics')">
|
||||
<div class="m-player-progress">
|
||||
<div class="m-player-progress-fill" :style="{ width: mobileProgressPct + '%' }" />
|
||||
<div v-if="currentSong" class="m-player" @click="onPlayerRowClick">
|
||||
<div
|
||||
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>
|
||||
<CoverArt :url="currentSong.coverUrl" :size="40" :radius="8" />
|
||||
<div class="m-player-info">
|
||||
@@ -79,7 +90,7 @@
|
||||
</template>
|
||||
|
||||
<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 { Icon } from '@iconify/vue';
|
||||
import { usePlayerStore } from './stores/player.js';
|
||||
@@ -132,15 +143,174 @@ let syncTimer: ReturnType<typeof setInterval> | null = null;
|
||||
let mobileRaf: number | null = null;
|
||||
|
||||
function updateMobileProgress() {
|
||||
const duration = currentSong.value?.duration ?? 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;
|
||||
// While the finger owns the bar, the clock must keep its hands off it — see
|
||||
// seekBarPct below. Skipping the write (rather than letting it be overridden)
|
||||
// also avoids 60 pointless reactive re-renders per second mid-drag.
|
||||
if (!seeking.value) {
|
||||
const duration = currentSong.value?.duration ?? 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);
|
||||
}
|
||||
|
||||
// --- 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() {
|
||||
mobileVolumeOpen.value = !mobileVolumeOpen.value;
|
||||
if (mobileVolumeOpen.value) mobileQueueOpen.value = false;
|
||||
@@ -242,15 +412,69 @@ onUnmounted(() => {
|
||||
top: 0;
|
||||
left: 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;
|
||||
border-radius: 1px;
|
||||
background: var(--border-color);
|
||||
}
|
||||
|
||||
.m-player-progress-fill {
|
||||
height: 2px;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
height: 100%;
|
||||
background: var(--color-primary);
|
||||
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 {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user