Files
teamspeak-music-bot/web/src/components/SongCard.vue
T
saopig1andClaude Opus 5 f777d892db 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>
2026-08-09 15:07:33 +08:00

258 lines
7.0 KiB
Vue

<template>
<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">
<div class="song-name-row">
<span class="song-name">{{ song.name }}</span>
<span
class="platform-badge"
:class="song.platform === 'bilibili' ? 'badge-bilibili' : song.platform === 'qq' ? 'badge-qq' : song.platform === 'youtube' ? 'badge-youtube' : song.platform === 'local' ? 'badge-local' : song.platform === 'kugou' ? 'badge-kugou' : song.platform === 'spotify' ? 'badge-spotify' : song.platform === 'jellyfin' ? 'badge-jellyfin' : 'badge-netease'"
>{{ song.platform === 'bilibili' ? 'B站' : song.platform === 'qq' ? 'QQ' : song.platform === 'youtube' ? 'YouTube' : song.platform === 'local' ? '本地' : song.platform === 'kugou' ? '酷狗' : song.platform === 'spotify' ? 'Spotify' : song.platform === 'jellyfin' ? 'Jellyfin' : '网易云' }}</span>
<span
v-if="song.requestedBy"
class="requester-badge"
:class="{ 'requester-badge-guest': song.requestedBy === '游客' }"
>{{ song.requestedBy }}</span>
</div>
<div class="song-artist">{{ song.artist }}</div>
</div>
<div class="song-album">{{ song.album }}</div>
<div class="song-duration">{{ formatDuration(song.duration) }}</div>
<div class="song-actions">
<button v-if="showPlay" class="action-btn" @click.stop="$emit('play')" title="播放">
<Icon icon="mdi:play" />
</button>
<button v-if="showPlayNext" class="action-btn" @click.stop="$emit('playNext')" title="下一首播放">
<Icon icon="mdi:playlist-play" />
</button>
<button v-if="showAdd" class="action-btn" @click.stop="$emit('add')" title="添加到队列">
<Icon icon="mdi:playlist-plus" />
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import { Icon } from '@iconify/vue';
import CoverArt from './CoverArt.vue';
import { Song } from '../stores/player.js';
import { useSession } from '../composables/useSession.js';
defineProps<{
song: Song;
index: number;
active?: boolean;
}>();
const { can, guestCan } = useSession();
const showPlay = computed(() => can('player.control') || guestCan('playNow'));
const showPlayNext = computed(() => can('player.control') || guestCan('playNext'));
const showAdd = computed(() => can('player.queue') || guestCan('addToQueue'));
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;
return `${m}:${s.toString().padStart(2, '0')}`;
}
</script>
<style lang="scss" scoped>
.song-card {
display: flex;
align-items: center;
gap: 12px;
padding: 8px 12px;
border-radius: var(--radius-md);
transition: background var(--transition-fast);
cursor: pointer;
&:hover {
background: var(--hover-bg);
.song-actions { opacity: 1; }
}
&.active {
background: var(--color-primary-10);
}
}
.song-index {
width: 24px;
text-align: center;
font-size: 13px;
color: var(--text-tertiary);
}
.song-info {
flex: 1;
min-width: 0;
}
.song-name-row {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
}
.song-name {
font-size: 14px;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.platform-badge {
flex-shrink: 0;
font-size: var(--fs-micro);
font-weight: var(--fw-semi);
padding: 1px 5px;
border-radius: var(--radius-xs);
line-height: 1.4;
}
.requester-badge {
flex-shrink: 0;
max-width: 96px;
font-size: var(--fs-micro);
font-weight: var(--fw-semi);
padding: 1px 5px;
border-radius: var(--radius-xs);
line-height: 1.4;
background: var(--color-online-15);
color: var(--color-online);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.requester-badge-guest {
background: rgba(156, 163, 175, 0.18);
color: rgba(156, 163, 175, 0.95);
}
.badge-netease {
background: var(--brand-netease-15);
color: var(--brand-netease);
}
.badge-qq {
background: var(--brand-qq-15);
color: var(--brand-qq);
}
.badge-bilibili {
background: var(--brand-bilibili-15);
color: var(--brand-bilibili);
}
.badge-youtube {
background: var(--brand-youtube-12);
color: var(--brand-youtube);
}
.badge-local {
background: var(--color-primary-10);
color: var(--color-primary);
}
.badge-kugou {
background: var(--brand-kugou-12);
color: var(--brand-kugou);
}
.badge-spotify {
background: var(--brand-spotify-12);
color: var(--brand-spotify);
}
.badge-jellyfin {
background: var(--brand-jellyfin-12);
color: var(--brand-jellyfin);
}
.song-artist {
font-size: 12px;
color: var(--text-secondary);
}
.song-album {
width: 160px;
font-size: 12px;
color: var(--text-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.song-duration {
width: 48px;
font-size: 12px;
color: var(--text-tertiary);
text-align: right;
}
.song-actions {
display: flex;
gap: 4px;
opacity: 0;
transition: opacity var(--transition-fast);
}
// Touch devices have no :hover, so the parent-hover-reveals-actions
// pattern leaves all action buttons invisible. Always show on coarse-
// pointer (touch) inputs — this is also where bigger tap targets matter.
@media (pointer: coarse) {
.song-actions {
opacity: 1;
}
}
.action-btn {
font-size: 18px;
padding: 4px;
border-radius: var(--radius-sm);
opacity: 0.7;
transition: opacity var(--transition-fast);
&:hover { opacity: 1; }
}
</style>