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
+288
-8
No files matched your search
+229
-5
@@ -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() {
|
||||||
|
// 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;
|
const duration = currentSong.value?.duration ?? 0;
|
||||||
// liveElapsed() recomputes each frame; the cached `elapsed` getter would
|
// liveElapsed() recomputes each frame; the cached `elapsed` getter would
|
||||||
// leave the mobile bar frozen between server pushes (#107).
|
// leave the mobile bar frozen between server pushes (#107).
|
||||||
mobileProgressPct.value = duration > 0
|
mobileProgressPct.value = duration > 0
|
||||||
? Math.min((playerStore.liveElapsed() / duration) * 100, 100)
|
? Math.min((playerStore.liveElapsed() / duration) * 100, 100)
|
||||||
: 0;
|
: 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;
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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;
|
||||||
|
|||||||
Reference in new issue
Block a user