Files
cube/apps/music/frontend/src/views/PlayerView.vue
T
Fam Zheng ccb5ad05ce
deploy music / build-and-deploy (push) Failing after 1m50s
music(inspire): 加「💡 今天练什么」灵感推荐 modal
- 后端 POST /api/inspire 流式 SSE:随机 keyword 池(23 个)+ 用户曲库画像(recent/top/least)+ Tavily 热点搜索 → gemma stream(temperature=1.0)
- Tavily key 走 k8s Secret tavily-creds(复用 mochi config 同一 token)
- 每次按按钮:keyword 随机 + 用户可输 hint("想练快歌" / "陪儿子" / "新东西")
- 输出强制格式:4 首歌('补回来' 2 + '试试新' 2),每首歌名-歌手 + 一句理由
- 前端 topbar 加 💡 按钮,modal 流式渲染(极简 md:**bold** + 列表)
2026-05-10 15:52:00 +01:00

1740 lines
48 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="root">
<header class="topbar">
<h1>🎵 Music</h1>
<input
class="search"
type="text"
v-model="search"
placeholder="搜索曲目 / 歌手"
/>
<span class="count">{{ filtered.length }} / {{ pieces.length }} </span>
<button class="btn-inspire" title="今天练什么?" @click="openInspire">💡</button>
<router-link to="/upload" class="btn-add" title="新增曲目"></router-link>
</header>
<nav class="filterbar">
<span class="fb-label">歌单</span>
<button
class="chip"
:class="{ active: !activePlaylistId }"
@click="setPlaylist(null)"
>全部</button>
<button
v-for="pl in playlists"
:key="pl.id"
class="chip"
:class="{ active: activePlaylistId === pl.id }"
@click="setPlaylist(pl.id)"
>{{ pl.name }}<span class="chip-n">{{ pl.count }}</span></button>
<button class="chip new-chip" @click="promptNewPlaylist"> 新歌单</button>
<span class="fb-sep">·</span>
<span class="fb-label">标签</span>
<button
class="chip"
:class="{ active: !activeTagName }"
@click="setTag(null)"
>全部</button>
<button
v-for="t in tagsList"
:key="t.id"
class="chip"
:class="{ active: activeTagName === t.name }"
@click="setTag(t.name)"
>{{ t.name }}<span class="chip-n">{{ t.count }}</span></button>
</nav>
<div class="main">
<aside class="sidebar" :class="{ 'has-selected': !!selected }">
<div class="sort-bar">
<button :class="{ active: sortMode === 'name' }" @click="setSort('name')">名称</button>
<button :class="{ active: sortMode === 'hot' }" @click="setSort('hot')">最多播放</button>
<button :class="{ active: sortMode === 'least' }" @click="setSort('least')">最少播放</button>
<button :class="{ active: sortMode === 'recent' }" @click="setSort('recent')">最近</button>
<button :class="{ active: sortMode === 'random' }" @click="setSort('random')">随机</button>
</div>
<div class="playlist">
<p v-if="loading" class="hint">加载中</p>
<p v-else-if="loadError" class="hint err">{{ loadError }}</p>
<p v-else-if="filtered.length === 0" class="hint">
空空如也<span v-if="activePlaylistId || activeTagName">当前筛选下</span>
</p>
<div
v-for="p in filtered"
:key="p.id"
class="row"
:class="{ active: selectedId === p.id }"
@click="selectPiece(p.id)"
>
<div class="row-main">
<div class="row-title">{{ p.title }}</div>
<div class="row-meta">
<span v-if="p.artist">{{ p.artist }}</span>
<span v-if="p.category" class="cat">{{ p.category }}</span>
<span v-for="tg in p.tags" :key="tg" class="tg">#{{ tg }}</span>
</div>
</div>
<div class="badges">
<span v-if="p.has_lyrics" class="badge" title="有歌词"></span>
<span v-for="k in iconKinds(p.kinds)" :key="k" class="badge" :title="k">{{ kindLabel(k) }}</span>
<span v-if="p.play_count > 0" class="play-count">{{ p.play_count }}</span>
</div>
</div>
</div>
</aside>
<section class="player-area">
<div v-if="!selected" class="empty">
<p>从左边挑一首吧 🎶</p>
</div>
<template v-else>
<header class="now-playing">
<h2>{{ selected.title }}</h2>
<div class="np-sub">
<span v-if="selected.artist">{{ selected.artist }}</span>
<span v-if="selected.category">· {{ selected.category }}</span>
<span v-if="selected.play_count">· 播放 {{ selected.play_count }} </span>
<router-link :to="{ name: 'edit', params: { id: selected.id } }" class="edit-link">编辑</router-link>
</div>
<div v-if="selected.tags && selected.tags.length" class="np-tags">
<span v-for="tg in selected.tags" :key="tg" class="tg">#{{ tg }}</span>
</div>
</header>
<nav v-if="tabs.length" class="tabs">
<button
v-for="t in tabs"
:key="t.key"
:class="{ active: activeTab === t.key }"
@click="setTab(t.key)"
>{{ t.label }}<span v-if="t.count > 1" class="tab-n">{{ t.count }}</span></button>
</nav>
<main class="content">
<!-- 歌词 -->
<div v-show="activeTab === 'lyrics'" class="lyrics-box" ref="lyricsBoxEl">
<div v-if="lyricsLines.length === 0" class="lyrics-none">
<span v-if="selected.lyrics">这首歌的歌词不是 LRC 格式</span>
<span v-else>暂无歌词用心感受 🎶</span>
<pre v-if="selected.lyrics" class="lyrics-raw">{{ selected.lyrics }}</pre>
</div>
<div
v-for="(line, i) in lyricsLines"
:key="i"
class="lyrics-line"
:class="{ active: i === activeLyricIdx }"
:data-i="i"
@click="seek(line.time)"
>{{ line.text }}</div>
</div>
<!-- 谱面弹唱谱 / 功能谱 共用渲染 -->
<div v-show="['chord', 'functional'].includes(activeTab)" class="sheet-box">
<img
v-for="att in chordTabAttachments(activeTab)"
:key="att.id"
:src="attachmentUrl(att.id)"
:alt="att.filename"
class="sheet-img"
@click="fullscreenSrc = attachmentUrl(att.id)"
/>
<div
v-if="chordTabAttachments(activeTab).length === 0"
class="auto-fetch"
>
<p v-if="chordStateOf(activeTab) === 'idle'" class="hint-line">
<span v-if="activeTab === 'chord'"> yopu.co <b>弹唱谱字母 G/Em/C</b></span>
<span v-else> yopu.co <b>功能谱数字 1/4/5/6m</b></span>
</p>
<p v-else-if="['pending','processing'].includes(chordStateOf(activeTab))" class="hint-line">
正在抓取 30-60s
</p>
<p v-else-if="chordStateOf(activeTab) === 'failed'" class="hint-line err">
抓取失败{{ chordErrors[modeForTab(activeTab)] }}
</p>
<button
class="btn-fetch"
:disabled="['pending','processing'].includes(chordStateOf(activeTab))"
@click="startChordFetch(modeForTab(activeTab))"
>
<span v-if="['pending','processing'].includes(chordStateOf(activeTab))" class="spin"></span>
<span v-else>🎸 自动抓取{{ activeTab === 'chord' ? '弹唱谱' : '功能谱' }}</span>
</button>
</div>
</div>
<!-- 简谱 / 五线谱手动上传的图 -->
<div v-show="['numbered', 'staff'].includes(activeTab)" class="sheet-box">
<img
v-for="att in roleAttachments(activeTab)"
:key="att.id"
:src="attachmentUrl(att.id)"
:alt="att.filename"
class="sheet-img"
@click="fullscreenSrc = attachmentUrl(att.id)"
/>
</div>
<!-- PDF -->
<div v-show="activeTab === 'pdf'" class="pdf-box">
<iframe
v-for="att in pdfAttachments"
:key="att.id"
:src="attachmentUrl(att.id)"
:title="att.filename"
class="pdf-frame"
/>
</div>
<!-- 视频 -->
<div v-show="activeTab === 'video'" class="video-box">
<video
v-for="att in videoAttachments"
:key="att.id"
:src="attachmentUrl(att.id)"
controls
class="video-el"
/>
</div>
<!-- 笔记独立 tab -->
<div v-show="activeTab === 'notes'" class="notes-box">
<div class="notes-head">
<span>练琴心得 / 难点 / 备注</span>
<span v-if="notesSavedFlash" class="saved">已保存</span>
</div>
<textarea
v-model="notesDraft"
@input="onNotesInput"
class="notes-area"
placeholder="自动保存。任何想法都丢这里…"
/>
</div>
</main>
<footer class="controls">
<div class="ctrl-row">
<label class="repeat" :class="{ on: repeatOne }" @click="repeatOne = !repeatOne">
<span>循环</span>
<span class="track"><span class="thumb"></span></span>
</label>
<button @click="prev" class="btn-icon" title="上一首"></button>
<button @click="togglePlay" class="btn-icon big" :title="playing ? '暂停' : '播放'">
{{ playing ? '⏸' : '▶' }}
</button>
<button @click="next" class="btn-icon" title="下一首"></button>
<span class="time">{{ fmtTime(currentTime) }}</span>
<div class="bar" @click="seekBar">
<div class="fill" :style="{ width: progressPct + '%' }"></div>
</div>
<span class="time">{{ fmtTime(duration) }}</span>
<button
class="btn-icon vol-icon"
:title="muted ? '取消静音' : '静音'"
@click="toggleMute"
>{{ volIcon }}</button>
<input
class="vol-slider"
type="range"
min="0"
max="100"
:value="muted ? 0 : Math.round(volume * 100)"
@input="onVolumeInput"
title="音量"
/>
</div>
</footer>
<audio
ref="audioEl"
@timeupdate="onTimeUpdate"
@loadedmetadata="onLoaded"
@ended="onEnded"
@play="playing = true"
@pause="playing = false"
/>
</template>
</section>
<!-- 灵感 modal -->
<div v-if="inspireOpen" class="ins-overlay" @click.self="closeInspire">
<div class="ins-modal">
<header class="ins-head">
<span>💡 今天练什么</span>
<button class="ins-close" @click="closeInspire"></button>
</header>
<div class="ins-hint-row">
<input
v-model="inspireHint"
class="ins-hint"
:disabled="inspireRunning"
placeholder="可选:心情/目标("想轻松点" / "陪儿子" / "学新东西""
@keydown.enter.prevent="runInspire"
/>
<button
class="ins-go"
:disabled="inspireRunning"
@click="runInspire"
>{{ inspireRunning ? '⏳ 生成中…' : '换一批' }}</button>
</div>
<div class="ins-body" v-html="inspireHtml"></div>
<p v-if="inspireError" class="ins-err">{{ inspireError }}</p>
</div>
</div>
<!-- 全屏乐谱 overlay再点一下关闭或按 ESC -->
<div
v-if="fullscreenSrc"
class="fs-overlay"
@click="fullscreenSrc = null"
>
<img :src="fullscreenSrc" class="fs-img" />
</div>
<!-- 右侧 LLM chat 边栏 -->
<aside v-if="selected" class="chat">
<header class="chat-head">
<span>Chat · {{ selected.title.slice(0, 16) }}</span>
<button class="chat-clear" @click="onChatClear" title="清空对话">清空</button>
</header>
<div ref="chatBodyEl" class="chat-body">
<p v-if="chatLoading" class="chat-empty">载入对话</p>
<p v-else-if="chatMessages.length === 0 && !chatStreaming" class="chat-empty">
随便聊点啥比如<br>
这首歌的副歌为啥用 6m <br>
我吉他扫弦节奏总不稳怎么办
</p>
<div
v-for="(m, i) in chatMessages"
:key="i"
class="msg"
:class="m.role"
>
<div class="msg-bubble">{{ m.content }}</div>
</div>
<div v-if="chatStreaming || chatStreamText" class="msg assistant">
<div class="msg-bubble">{{ chatStreamText || '…' }}</div>
</div>
<p v-if="chatError" class="chat-err">{{ chatError }}</p>
</div>
<div class="chat-input">
<textarea
v-model="chatDraft"
@keydown.enter.exact.prevent="onChatSend"
@keydown.enter.shift.exact="$event => null"
:disabled="chatStreaming"
placeholder="Enter 发送 · Shift+Enter 换行"
rows="2"
/>
<button
class="chat-send"
:disabled="chatStreaming || !chatDraft.trim()"
@click="onChatSend"
>{{ chatStreaming ? '⏳' : '↑' }}</button>
</div>
</aside>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, onBeforeUnmount, watch, nextTick } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import {
listPieces,
listPlaylists,
listTags,
createPlaylist,
getPiece,
patchPiece,
recordPlay,
attachmentUrl as attUrl,
chordFetch,
chordStatus,
listChat,
clearChat,
streamChat,
streamInspire,
} from '../lib/api.js'
import { parseLrc } from '../lib/lrc.js'
const route = useRoute()
const router = useRouter()
const pieces = ref([])
const loading = ref(true)
const loadError = ref('')
const selected = ref(null)
const selectedId = ref(null)
const playlists = ref([])
const tagsList = ref([])
const activePlaylistId = ref(null)
const activeTagName = ref(null)
const search = ref('')
const sortMode = ref(localStorage.getItem('music.sort') || 'name')
const repeatOne = ref(false)
const volume = ref(parseFloat(localStorage.getItem('music.vol') || '1'))
const muted = ref(localStorage.getItem('music.muted') === '1')
const volIcon = computed(() => {
if (muted.value || volume.value === 0) return '🔇'
if (volume.value < 0.34) return '🔈'
if (volume.value < 0.67) return '🔉'
return '🔊'
})
function applyVolume() {
if (audioEl.value) {
audioEl.value.volume = volume.value
audioEl.value.muted = muted.value
}
}
function onVolumeInput(e) {
const v = parseInt(e.target.value, 10) / 100
volume.value = isNaN(v) ? 0 : Math.max(0, Math.min(1, v))
if (volume.value > 0 && muted.value) muted.value = false
localStorage.setItem('music.vol', String(volume.value))
localStorage.setItem('music.muted', muted.value ? '1' : '0')
applyVolume()
}
function toggleMute() {
muted.value = !muted.value
localStorage.setItem('music.muted', muted.value ? '1' : '0')
applyVolume()
}
const audioEl = ref(null)
const lyricsBoxEl = ref(null)
const playing = ref(false)
const currentTime = ref(0)
const duration = ref(0)
const activeTab = ref('lyrics')
const notesDraft = ref('')
const notesSavedFlash = ref(false)
let notesTimer = null
let randomSeed = Math.random()
let lastReportedId = null
// fullscreen 乐谱
const fullscreenSrc = ref(null)
// 灵感 modal
const inspireOpen = ref(false)
const inspireHint = ref('')
const inspireText = ref('')
const inspireRunning = ref(false)
const inspireError = ref('')
let inspireAbort = null
const inspireHtml = computed(() => mdLite(inspireText.value))
// 极简 markdown**粗体** + 列表 + 换行 → html
function mdLite(s) {
if (!s) return '<p class="ins-empty">点「换一批」让 LLM 给你推几首</p>'
// 转义 html,保留我们后面要插的 tag
let h = s
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
// **bold**
h = h.replace(/\*\*(.+?)\*\*/g, '<b>$1</b>')
// 行首 - / * 列表
h = h.replace(/(^|\n)\s*[-*]\s+/g, '$1• ')
// 段落间双换行 → <p>
return h.split(/\n{2,}/).map(p => `<p>${p.replace(/\n/g, '<br>')}</p>`).join('')
}
function openInspire() {
inspireOpen.value = true
if (!inspireText.value) runInspire()
}
function closeInspire() {
if (inspireAbort) { try { inspireAbort.abort() } catch {} ; inspireAbort = null }
inspireRunning.value = false
inspireOpen.value = false
}
async function runInspire() {
if (inspireRunning.value) return
inspireText.value = ''
inspireError.value = ''
inspireRunning.value = true
const ctrl = new AbortController()
inspireAbort = ctrl
try {
const r = await streamInspire(inspireHint.value.trim(), (delta) => {
inspireText.value += delta
}, ctrl.signal)
if (!r.ok) inspireError.value = r.error || '出错'
} catch (e) {
if (e.name !== 'AbortError') inspireError.value = e.message || String(e)
} finally {
inspireRunning.value = false
inspireAbort = null
}
}
// chord —— 两个 mode 各自独立 state
const chordStates = ref({ letters: 'idle', functional: 'idle' })
const chordErrors = ref({ letters: '', functional: '' })
const chordPollTimers = { letters: null, functional: null }
const chordPollStarted = { letters: 0, functional: 0 }
function chordStateOf(tab) {
return chordStates.value[modeForTab(tab)] || 'idle'
}
// chat
const chatBodyEl = ref(null)
const chatMessages = ref([])
const chatLoading = ref(false)
const chatDraft = ref('')
const chatStreaming = ref(false)
const chatStreamText = ref('')
const chatError = ref('')
let chatAbort = null
const lyricsLines = computed(() => parseLrc(selected.value?.lyrics || ''))
const activeLyricIdx = computed(() => {
const lines = lyricsLines.value
if (!lines.length) return -1
let idx = -1
for (let i = lines.length - 1; i >= 0; i--) {
if (currentTime.value >= lines[i].time) { idx = i; break }
}
return idx
})
const audioAttachments = computed(() =>
(selected.value?.attachments || []).filter(a => a.kind === 'audio'))
const videoAttachments = computed(() =>
(selected.value?.attachments || []).filter(a => a.kind === 'video'))
const pdfAttachments = computed(() =>
(selected.value?.attachments || []).filter(a => a.kind === 'pdf'))
function roleAttachments(role) {
return (selected.value?.attachments || []).filter(
a => a.kind === 'image' && a.role === role,
)
}
// chord 兼容:吉他谱 tab 显示历史 role='chord' + 新 role='chord_letters';功能谱 tab 显示 role='chord_functional'
function chordTabAttachments(tab) {
const set = tab === 'chord'
? new Set(['chord', 'chord_letters'])
: new Set(['chord_functional'])
return (selected.value?.attachments || []).filter(
a => a.kind === 'image' && set.has(a.role),
)
}
function modeForTab(tab) {
return tab === 'functional' ? 'functional' : 'letters'
}
const tabs = computed(() => {
if (!selected.value) return []
const list = []
if (selected.value.lyrics) list.push({ key: 'lyrics', label: '歌词', count: 0 })
list.push({ key: 'chord', label: '吉他谱', count: chordTabAttachments('chord').length })
list.push({ key: 'functional', label: '功能谱', count: chordTabAttachments('functional').length })
const num = roleAttachments('numbered').length
if (num) list.push({ key: 'numbered', label: '简谱', count: num })
const staff = roleAttachments('staff').length
if (staff) list.push({ key: 'staff', label: '五线谱', count: staff })
if (pdfAttachments.value.length) list.push({ key: 'pdf', label: '乐谱 PDF', count: pdfAttachments.value.length })
if (videoAttachments.value.length) list.push({ key: 'video', label: '视频', count: videoAttachments.value.length })
list.push({ key: 'notes', label: '笔记', count: 0 })
return list
})
const filtered = computed(() => {
const q = search.value.trim().toLowerCase()
let arr = pieces.value
if (q) {
arr = arr.filter(p => {
const hay = `${p.title} ${p.artist || ''} ${p.category || ''} ${(p.tags || []).join(' ')}`.toLowerCase()
return hay.includes(q)
})
}
arr = [...arr]
switch (sortMode.value) {
case 'hot':
arr.sort((a, b) => b.play_count - a.play_count || a.title.localeCompare(b.title, 'zh'))
break
case 'least':
arr.sort((a, b) => a.play_count - b.play_count || a.title.localeCompare(b.title, 'zh'))
break
case 'recent':
arr.sort((a, b) => {
const ta = a.last_played_at || ''
const tb = b.last_played_at || ''
return tb.localeCompare(ta) || a.title.localeCompare(b.title, 'zh')
})
break
case 'random': {
const seeded = arr.map(p => ({ p, k: hash(p.id, randomSeed) }))
seeded.sort((a, b) => a.k - b.k)
arr = seeded.map(x => x.p)
break
}
default:
arr.sort((a, b) => a.title.localeCompare(b.title, 'zh'))
}
return arr
})
function hash(id, seed) {
let x = (id ^ Math.floor(seed * 1e9)) >>> 0
x = (x ^ (x << 13)) >>> 0
x = (x ^ (x >>> 17)) >>> 0
x = (x ^ (x << 5)) >>> 0
return x
}
function setSort(mode) {
if (mode === 'random' && sortMode.value === 'random') {
randomSeed = Math.random()
}
sortMode.value = mode
localStorage.setItem('music.sort', mode)
}
function iconKinds(kinds) {
const order = ['audio', 'video', 'pdf', 'image']
return order.filter(k => kinds.includes(k))
}
function kindLabel(k) {
return ({ audio: '音', video: '视', pdf: 'PDF', image: '谱' })[k] || k
}
async function loadPieces() {
loading.value = true
loadError.value = ''
try {
pieces.value = await listPieces({
tag: activeTagName.value,
playlist: activePlaylistId.value,
})
} catch (e) {
loadError.value = e.message || String(e)
} finally {
loading.value = false
}
}
async function loadPlaylists() {
try { playlists.value = await listPlaylists() } catch {}
}
async function loadTags() {
try { tagsList.value = await listTags() } catch {}
}
async function setPlaylist(id) {
activePlaylistId.value = id
if (id) activeTagName.value = null
await loadPieces()
}
async function setTag(name) {
activeTagName.value = name
if (name) activePlaylistId.value = null
await loadPieces()
}
async function promptNewPlaylist() {
const name = prompt('新歌单名(如:我喜欢的 / 儿子在练)')
if (!name || !name.trim()) return
try {
const r = await createPlaylist(name.trim())
await loadPlaylists()
setPlaylist(r.id)
} catch (e) {
alert(e.message || String(e))
}
}
async function loadPiece(id) {
selected.value = null
notesDraft.value = ''
stopChordPoll('letters'); stopChordPoll('functional')
chordStates.value = { letters: 'idle', functional: 'idle' }
chordErrors.value = { letters: '', functional: '' }
abortChat()
chatMessages.value = []
chatStreamText.value = ''
chatError.value = ''
if (!id) return
try {
const p = await getPiece(id)
selected.value = p
notesDraft.value = p.notes || ''
selectedId.value = p.id
const t = tabs.value
if (!t.find(x => x.key === activeTab.value)) {
activeTab.value = t[0]?.key || 'lyrics'
}
await nextTick()
const first = audioAttachments.value[0]
if (first && audioEl.value) {
audioEl.value.src = attUrl(first.id)
audioEl.value.play().catch(() => {})
} else if (audioEl.value) {
audioEl.value.removeAttribute('src')
audioEl.value.load()
}
lastReportedId = null
// 加载 chat 历史
loadChat(id)
} catch (e) {
loadError.value = e.message || String(e)
}
}
async function loadChat(id) {
chatLoading.value = true
try {
const arr = await listChat(id)
if (selectedId.value === id) chatMessages.value = arr || []
} catch (e) {
chatError.value = e.message || String(e)
} finally {
chatLoading.value = false
}
}
function selectPiece(id) {
router.push({ name: 'piece', params: { id } })
}
function attachmentUrl(id) { return attUrl(id) }
function togglePlay() {
if (!audioEl.value || !audioEl.value.src) return
if (audioEl.value.paused) audioEl.value.play()
else audioEl.value.pause()
}
function next() {
if (!filtered.value.length) return
const idx = filtered.value.findIndex(p => p.id === selectedId.value)
const nextIdx = (idx + 1) % filtered.value.length
selectPiece(filtered.value[nextIdx].id)
}
function prev() {
if (!filtered.value.length) return
const idx = filtered.value.findIndex(p => p.id === selectedId.value)
const prevIdx = (idx - 1 + filtered.value.length) % filtered.value.length
selectPiece(filtered.value[prevIdx].id)
}
function seek(t) {
if (!audioEl.value) return
audioEl.value.currentTime = t
if (audioEl.value.paused) audioEl.value.play().catch(() => {})
}
function seekBar(e) {
if (!audioEl.value || !duration.value) return
const rect = e.currentTarget.getBoundingClientRect()
const ratio = (e.clientX - rect.left) / rect.width
audioEl.value.currentTime = Math.max(0, Math.min(1, ratio)) * duration.value
}
function onTimeUpdate(e) {
currentTime.value = e.target.currentTime
if (selectedId.value && lastReportedId !== selectedId.value && currentTime.value >= 10) {
lastReportedId = selectedId.value
recordPlay(selectedId.value).then(d => {
if (selected.value) selected.value.play_count = d.play_count
const inList = pieces.value.find(p => p.id === selectedId.value)
if (inList) {
inList.play_count = d.play_count
inList.last_played_at = new Date().toISOString().replace('T', ' ').slice(0, 19)
}
}).catch(() => {})
}
if (activeTab.value === 'lyrics' && lyricsBoxEl.value) {
const idx = activeLyricIdx.value
if (idx >= 0) {
const el = lyricsBoxEl.value.querySelector(`.lyrics-line[data-i="${idx}"]`)
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'center' })
}
}
if (selectedId.value) {
localStorage.setItem('music.last', JSON.stringify({
id: selectedId.value,
time: currentTime.value,
}))
}
}
function onLoaded(e) {
duration.value = e.target.duration || 0
applyVolume()
}
function onEnded() {
if (repeatOne.value && audioEl.value) {
audioEl.value.currentTime = 0
audioEl.value.play().catch(() => {})
} else {
next()
}
}
const progressPct = computed(() => {
if (!duration.value) return 0
return Math.max(0, Math.min(100, (currentTime.value / duration.value) * 100))
})
function fmtTime(s) {
if (!s || isNaN(s)) return '0:00'
const m = Math.floor(s / 60)
const sec = Math.floor(s % 60)
return m + ':' + (sec < 10 ? '0' : '') + sec
}
function setTab(k) {
activeTab.value = k
}
// notes auto-save
function onNotesInput() {
if (!selectedId.value) return
if (notesTimer) clearTimeout(notesTimer)
notesTimer = setTimeout(async () => {
try {
await patchPiece(selectedId.value, { notes: notesDraft.value || null })
notesSavedFlash.value = true
setTimeout(() => (notesSavedFlash.value = false), 1500)
} catch {}
}, 600)
}
// chord
async function startChordFetch(mode) {
if (!selectedId.value) return
chordStates.value = { ...chordStates.value, [mode]: 'pending' }
chordErrors.value = { ...chordErrors.value, [mode]: '' }
try {
const r = await chordFetch(selectedId.value, mode)
if (r.status === 'completed') {
await reloadPiece()
chordStates.value = { ...chordStates.value, [mode]: 'completed' }
return
}
chordStates.value = { ...chordStates.value, [mode]: r.status || 'pending' }
chordPollStarted[mode] = Date.now()
if (chordPollTimers[mode]) clearInterval(chordPollTimers[mode])
chordPollTimers[mode] = setInterval(() => pollChord(mode), 3000)
} catch (e) {
chordStates.value = { ...chordStates.value, [mode]: 'failed' }
chordErrors.value = { ...chordErrors.value, [mode]: e.message || String(e) }
}
}
async function pollChord(mode) {
if (!selectedId.value) { stopChordPoll(mode); return }
if (Date.now() - chordPollStarted[mode] > 90_000) {
stopChordPoll(mode)
chordStates.value = { ...chordStates.value, [mode]: 'failed' }
chordErrors.value = { ...chordErrors.value, [mode]: '抓取超时' }
return
}
try {
const r = await chordStatus(selectedId.value, mode)
chordStates.value = { ...chordStates.value, [mode]: r.status || 'pending' }
chordErrors.value = { ...chordErrors.value, [mode]: r.error || '' }
if (r.status === 'completed') {
stopChordPoll(mode)
await reloadPiece()
} else if (r.status === 'failed') {
stopChordPoll(mode)
}
} catch (e) {
chordErrors.value = { ...chordErrors.value, [mode]: e.message || String(e) }
}
}
function stopChordPoll(mode) {
if (chordPollTimers[mode]) {
clearInterval(chordPollTimers[mode])
chordPollTimers[mode] = null
}
}
async function reloadPiece() {
if (!selectedId.value) return
try {
const fresh = await getPiece(selectedId.value)
selected.value = fresh
} catch {}
}
// chat
async function onChatSend() {
const msg = chatDraft.value.trim()
if (!msg || chatStreaming.value || !selectedId.value) return
// 乐观追加 user msgassistant 流完整结束后会从 db 读最终 +1)
chatMessages.value = [
...chatMessages.value,
{ role: 'user', content: msg, id: Date.now() },
]
chatDraft.value = ''
chatStreamText.value = ''
chatError.value = ''
chatStreaming.value = true
await nextTick(); scrollChatBottom()
const ctrl = new AbortController()
chatAbort = ctrl
const pidAtStart = selectedId.value
try {
const result = await streamChat(pidAtStart, msg, (delta) => {
if (selectedId.value !== pidAtStart) return
chatStreamText.value += delta
scrollChatBottom()
}, ctrl.signal)
if (selectedId.value !== pidAtStart) return
if (result.ok) {
chatMessages.value = [
...chatMessages.value,
{ role: 'assistant', content: chatStreamText.value, id: Date.now() + 1 },
]
} else {
chatError.value = result.error || '出错'
}
} catch (e) {
if (e.name !== 'AbortError') chatError.value = e.message || String(e)
} finally {
chatStreamText.value = ''
chatStreaming.value = false
chatAbort = null
await nextTick(); scrollChatBottom()
}
}
async function onChatClear() {
if (!selectedId.value || !confirm('清空这首歌的对话历史?')) return
abortChat()
try {
await clearChat(selectedId.value)
chatMessages.value = []
chatStreamText.value = ''
chatError.value = ''
} catch (e) {
alert(e.message || String(e))
}
}
function abortChat() {
if (chatAbort) {
try { chatAbort.abort() } catch {}
chatAbort = null
}
chatStreaming.value = false
chatStreamText.value = ''
}
function scrollChatBottom() {
const el = chatBodyEl.value
if (el) el.scrollTop = el.scrollHeight
}
// keyboard
function onKeyDown(e) {
// ESC 在任何输入态下都能关全屏
if (e.key === 'Escape' && fullscreenSrc.value) {
e.preventDefault()
fullscreenSrc.value = null
return
}
const tag = (e.target.tagName || '').toLowerCase()
if (tag === 'input' || tag === 'textarea') return
if (e.code === 'Space') { e.preventDefault(); togglePlay() }
else if (e.code === 'ArrowRight') {
if (audioEl.value) audioEl.value.currentTime = Math.min(audioEl.value.currentTime + 5, duration.value)
}
else if (e.code === 'ArrowLeft') {
if (audioEl.value) audioEl.value.currentTime = Math.max(audioEl.value.currentTime - 5, 0)
}
else if (e.key === 'Tab') {
e.preventDefault()
const idx = tabs.value.findIndex(t => t.key === activeTab.value)
const nx = tabs.value[(idx + 1) % tabs.value.length]
if (nx) activeTab.value = nx.key
}
}
// route → selected
watch(() => route.params.id, async (idStr) => {
const id = idStr ? Number(idStr) : null
if (id !== selectedId.value) {
selectedId.value = id
if (id) await loadPiece(id)
else selected.value = null
}
}, { immediate: false })
onMounted(async () => {
document.addEventListener('keydown', onKeyDown)
await Promise.all([loadPieces(), loadPlaylists(), loadTags()])
const id = route.params.id ? Number(route.params.id) : null
if (id) {
selectedId.value = id
await loadPiece(id)
} else {
try {
const last = JSON.parse(localStorage.getItem('music.last') || 'null')
if (last && pieces.value.find(p => p.id === last.id)) {
router.replace({ name: 'piece', params: { id: last.id } })
}
} catch {}
}
})
onBeforeUnmount(() => {
document.removeEventListener('keydown', onKeyDown)
if (notesTimer) clearTimeout(notesTimer)
stopChordPoll('letters'); stopChordPoll('functional')
abortChat()
})
</script>
<style scoped>
.root {
height: 100%;
display: flex;
flex-direction: column;
background: var(--bg);
}
.topbar {
display: flex;
align-items: center;
gap: 14px;
padding: 12px 20px;
background: var(--bg-card);
border-bottom: 1px solid var(--border-soft);
flex-shrink: 0;
}
.topbar h1 { font-size: 18px; font-weight: 600; white-space: nowrap; }
.topbar .search {
flex: 1;
max-width: 380px;
padding: 8px 14px;
border-radius: 20px;
border: 1px solid var(--border);
background: var(--bg);
font-size: 14px;
}
.topbar .search:focus { border-color: var(--accent-strong); }
.topbar .count { color: var(--text-mute); font-size: 12px; white-space: nowrap; }
.btn-inspire {
width: 36px; height: 36px;
border-radius: 50%;
background: rgba(192, 132, 252, 0.15);
color: var(--accent);
font-size: 16px;
display: inline-flex; align-items: center; justify-content: center;
}
.btn-inspire:hover { background: rgba(192, 132, 252, 0.3); }
.ins-overlay {
position: fixed;
inset: 0;
z-index: 300;
background: rgba(0, 0, 0, 0.7);
display: flex;
align-items: flex-start;
justify-content: center;
padding: 60px 16px 16px;
backdrop-filter: blur(2px);
}
.ins-modal {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 12px;
width: 100%;
max-width: 640px;
max-height: calc(100vh - 76px);
display: flex;
flex-direction: column;
overflow: hidden;
box-shadow: 0 12px 40px rgba(0,0,0,0.5);
}
.ins-head {
padding: 14px 18px;
font-size: 16px;
font-weight: 600;
border-bottom: 1px solid var(--border-soft);
display: flex;
justify-content: space-between;
align-items: center;
color: var(--accent);
}
.ins-close {
font-size: 16px;
color: var(--text-mute);
width: 28px; height: 28px;
border-radius: 50%;
}
.ins-close:hover { background: rgba(255,255,255,0.06); color: var(--text); }
.ins-hint-row {
display: flex;
gap: 8px;
padding: 12px 18px;
border-bottom: 1px solid var(--border-soft);
}
.ins-hint {
flex: 1;
background: var(--bg-elev);
border: 1px solid var(--border);
border-radius: 6px;
padding: 8px 12px;
font-size: 13px;
color: var(--text);
}
.ins-hint:focus { border-color: var(--accent-strong); outline: none; }
.ins-go {
background: var(--accent-strong);
color: #fff;
padding: 8px 16px;
border-radius: 6px;
font-size: 13px;
font-weight: 600;
white-space: nowrap;
}
.ins-go:hover:not(:disabled) { background: var(--accent); }
.ins-body {
flex: 1;
overflow-y: auto;
padding: 16px 18px 24px;
font-size: 14px;
line-height: 1.7;
color: var(--text);
}
.ins-body :deep(p) { margin: 0 0 8px; }
.ins-body :deep(b) { color: var(--accent); }
.ins-body :deep(.ins-empty) { color: var(--text-mute); text-align: center; padding: 40px 0; }
.ins-err {
margin: 0 18px 16px;
color: var(--accent-red);
background: rgba(239,68,68,0.08);
padding: 8px 12px;
border-radius: 6px;
font-size: 13px;
}
.topbar .btn-add {
width: 36px; height: 36px;
border-radius: 50%;
background: var(--accent-strong);
color: #fff;
font-size: 22px; font-weight: 600;
display: inline-flex; align-items: center; justify-content: center;
text-decoration: none;
}
.topbar .btn-add:hover { background: var(--accent); text-decoration: none; }
.filterbar {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 20px;
background: var(--bg-elev);
border-bottom: 1px solid var(--border-soft);
flex-wrap: wrap;
flex-shrink: 0;
font-size: 12px;
}
.fb-label {
color: var(--text-mute);
text-transform: uppercase;
letter-spacing: 0.06em;
font-size: 10px;
margin-right: 2px;
}
.fb-sep { color: var(--text-mute); margin: 0 4px; }
.chip {
background: var(--bg);
border: 1px solid var(--border);
color: var(--text-dim);
padding: 4px 10px;
border-radius: 12px;
font-size: 12px;
display: inline-flex;
align-items: center;
gap: 4px;
transition: all 0.15s;
}
.chip:hover { color: var(--text); }
.chip.active {
background: var(--bg-active);
border-color: var(--accent-strong);
color: var(--accent);
}
.chip-n {
font-size: 10px;
color: var(--text-mute);
}
.new-chip {
border-style: dashed;
color: var(--text-mute);
}
.main {
flex: 1;
display: flex;
overflow: hidden;
min-height: 0;
}
.sidebar {
width: 320px;
min-width: 260px;
border-right: 1px solid var(--border-soft);
display: flex;
flex-direction: column;
flex-shrink: 0;
}
.sort-bar {
display: flex;
padding: 6px 8px;
gap: 0;
border-bottom: 1px solid var(--border-soft);
flex-wrap: wrap;
}
.sort-bar button {
font-size: 11px;
padding: 5px 10px;
color: var(--text-mute);
border: 1px solid var(--border);
background: var(--bg-elev);
border-right-width: 0;
}
.sort-bar button:first-child { border-radius: 4px 0 0 4px; }
.sort-bar button:last-child { border-radius: 0 4px 4px 0; border-right-width: 1px; }
.sort-bar button:hover { color: var(--text); }
.sort-bar button.active {
background: var(--bg-active);
border-color: var(--accent-strong);
color: var(--accent);
}
.playlist { flex: 1; overflow-y: auto; }
.hint { padding: 40px 20px; text-align: center; color: var(--text-mute); font-size: 14px; }
.hint.err { color: var(--accent-red); }
.row {
display: flex;
padding: 10px 14px;
border-bottom: 1px solid var(--border-soft);
cursor: pointer;
align-items: center;
gap: 10px;
}
.row:hover { background: var(--bg-card); }
.row.active { background: var(--bg-active); }
.row.active .row-title { color: var(--accent); }
.row-main { flex: 1; min-width: 0; }
.row-title {
font-size: 14px;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.row-meta {
font-size: 11px;
color: var(--text-mute);
margin-top: 2px;
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.row-meta .cat {
color: var(--accent-cyan);
background: rgba(6, 182, 212, 0.1);
padding: 0 6px;
border-radius: 8px;
}
.tg {
color: var(--accent);
background: rgba(192, 132, 252, 0.1);
padding: 0 6px;
border-radius: 8px;
font-size: 11px;
}
.np-tags { display: flex; gap: 6px; justify-content: center; margin-top: 4px; flex-wrap: wrap; }
.badges {
display: flex;
gap: 4px;
align-items: center;
flex-shrink: 0;
}
.badge {
font-size: 10px;
color: var(--accent-strong);
background: rgba(124, 92, 191, 0.12);
padding: 1px 6px;
border-radius: 8px;
}
.play-count {
font-size: 10px;
color: var(--text-mute);
}
.player-area {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
min-width: 0;
}
.empty {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
color: var(--text-mute);
font-size: 16px;
}
.now-playing {
padding: 16px 24px 6px;
text-align: center;
flex-shrink: 0;
}
.now-playing h2 {
font-size: 22px;
color: var(--accent);
margin-bottom: 4px;
}
.np-sub {
color: var(--text-dim);
font-size: 13px;
display: flex;
gap: 6px;
justify-content: center;
flex-wrap: wrap;
}
.edit-link {
margin-left: 6px;
color: var(--text-mute);
font-size: 12px;
}
.edit-link:hover { color: var(--accent); }
.tabs {
display: flex;
gap: 0;
padding: 0 24px;
border-bottom: 1px solid var(--border-soft);
flex-shrink: 0;
overflow-x: auto;
}
.tabs button {
background: none;
color: var(--text-mute);
border-bottom: 2px solid transparent;
padding: 10px 16px;
font-size: 14px;
white-space: nowrap;
transition: all 0.2s;
}
.tabs button:hover { color: var(--text-dim); }
.tabs button.active {
color: var(--accent);
border-bottom-color: var(--accent);
}
.tabs .tab-n {
font-size: 10px;
color: var(--text-mute);
margin-left: 4px;
}
.content {
flex: 1;
overflow-y: auto;
padding: 12px 24px 80px;
min-height: 0;
}
.lyrics-box .lyrics-line {
padding: 8px 0;
font-size: 16px;
text-align: center;
color: var(--text-mute);
line-height: 1.6;
cursor: pointer;
transition: color 0.3s, font-size 0.3s;
}
.lyrics-box .lyrics-line.active {
color: var(--text);
font-size: 19px;
font-weight: 500;
}
.lyrics-none {
text-align: center;
color: var(--text-mute);
margin-top: 60px;
font-size: 14px;
}
.lyrics-raw {
margin-top: 20px;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
text-align: left;
white-space: pre-wrap;
word-break: break-all;
font-size: 12px;
background: var(--bg-elev);
padding: 12px;
border-radius: 6px;
}
.sheet-box {
display: flex;
flex-direction: column;
gap: 16px;
align-items: center;
}
.sheet-img {
max-width: 100%;
border-radius: 8px;
background: #fff;
cursor: zoom-in;
transition: transform 0.1s;
}
.sheet-img:hover { transform: scale(1.005); }
.fs-overlay {
position: fixed;
inset: 0;
z-index: 200;
background: rgba(0, 0, 0, 0.96);
overflow: auto;
display: flex;
justify-content: center;
align-items: flex-start;
padding: 16px;
cursor: zoom-out;
}
.fs-img {
max-width: 100%;
height: auto;
display: block;
background: #fff;
border-radius: 4px;
}
.auto-fetch {
margin-top: 40px;
text-align: center;
display: flex;
flex-direction: column;
align-items: center;
gap: 14px;
}
.auto-fetch .hint-line {
color: var(--text-mute);
font-size: 14px;
line-height: 1.6;
}
.auto-fetch .hint-line b { color: var(--accent); }
.auto-fetch .hint-line.err { color: var(--accent-red); }
.btn-fetch {
background: var(--accent-strong);
color: #fff;
padding: 12px 22px;
border-radius: 8px;
font-size: 14px;
font-weight: 600;
}
.btn-fetch:hover:not(:disabled) { background: var(--accent); }
.btn-fetch .spin { display: inline-block; animation: spin-anim 1.5s linear infinite; }
@keyframes spin-anim { to { transform: rotate(360deg); } }
.pdf-box { display: flex; flex-direction: column; gap: 16px; }
.pdf-frame {
width: 100%;
height: 90vh;
border: none;
background: #fff;
border-radius: 6px;
}
.video-box { display: flex; flex-direction: column; gap: 16px; align-items: center; }
.video-el {
max-width: 100%;
width: 100%;
max-height: 60vh;
background: #000;
border-radius: 8px;
}
.notes-box {
display: flex;
flex-direction: column;
height: 100%;
gap: 8px;
}
.notes-head {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 12px;
color: var(--text-mute);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.notes-head .saved { color: var(--accent-green); }
.notes-area {
flex: 1;
min-height: 240px;
background: var(--bg-elev);
border: 1px solid var(--border);
border-radius: 8px;
padding: 14px 16px;
font-size: 14px;
line-height: 1.6;
resize: vertical;
}
.notes-area:focus { border-color: var(--accent-strong); }
.controls {
flex-shrink: 0;
background: var(--bg-card);
padding: 12px 20px;
border-top: 1px solid var(--border);
}
.ctrl-row {
display: flex;
align-items: center;
gap: 12px;
max-width: 900px;
margin: 0 auto;
}
.btn-icon {
font-size: 22px;
color: var(--text-dim);
width: 40px;
height: 40px;
border-radius: 50%;
display: inline-flex;
align-items: center;
justify-content: center;
}
.btn-icon:hover { background: rgba(255,255,255,0.06); color: var(--text); }
.btn-icon.big {
font-size: 26px;
background: var(--accent-strong);
color: #fff;
}
.btn-icon.big:hover { background: var(--accent); color: #fff; }
.repeat {
display: flex;
align-items: center;
gap: 6px;
cursor: pointer;
user-select: none;
color: var(--text-mute);
font-size: 11px;
}
.repeat .track {
width: 30px; height: 16px;
border-radius: 8px;
background: var(--border);
position: relative;
transition: background 0.2s;
}
.repeat .thumb {
position: absolute;
top: 2px; left: 2px;
width: 12px; height: 12px;
border-radius: 50%;
background: var(--text-mute);
transition: transform 0.2s, background 0.2s;
}
.repeat.on .track { background: var(--accent-strong); }
.repeat.on .thumb { transform: translateX(14px); background: #fff; }
.bar {
flex: 1;
height: 4px;
background: var(--border);
border-radius: 2px;
cursor: pointer;
position: relative;
}
.fill {
height: 100%;
background: var(--accent-strong);
border-radius: 2px;
}
.time {
font-size: 12px;
color: var(--text-dim);
min-width: 40px;
text-align: center;
}
.vol-icon { font-size: 16px; width: 32px; height: 32px; }
.vol-slider {
-webkit-appearance: none;
appearance: none;
width: 90px;
height: 4px;
background: var(--border);
border-radius: 2px;
cursor: pointer;
}
.vol-slider:focus { outline: none; }
.vol-slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 12px;
height: 12px;
background: var(--accent-strong);
border-radius: 50%;
cursor: pointer;
}
.vol-slider::-moz-range-thumb {
width: 12px;
height: 12px;
background: var(--accent-strong);
border: none;
border-radius: 50%;
cursor: pointer;
}
@media (max-width: 768px) {
.vol-slider { width: 60px; }
}
/* Chat sidebar */
.chat {
width: 320px;
border-left: 1px solid var(--border-soft);
background: var(--bg-elev);
display: none;
flex-direction: column;
flex-shrink: 0;
}
@media (min-width: 1280px) {
.chat { display: flex; }
}
.chat-head {
padding: 10px 14px;
font-size: 12px;
color: var(--text-mute);
border-bottom: 1px solid var(--border-soft);
display: flex;
justify-content: space-between;
align-items: center;
}
.chat-clear {
background: rgba(239, 68, 68, 0.1);
color: var(--accent-red);
font-size: 11px;
padding: 3px 8px;
border-radius: 4px;
}
.chat-clear:hover { background: rgba(239, 68, 68, 0.2); }
.chat-body {
flex: 1;
overflow-y: auto;
padding: 12px 12px 8px;
display: flex;
flex-direction: column;
gap: 8px;
}
.chat-empty {
color: var(--text-mute);
font-size: 13px;
text-align: center;
padding: 30px 8px;
line-height: 1.7;
}
.chat-err {
color: var(--accent-red);
font-size: 12px;
background: rgba(239, 68, 68, 0.08);
padding: 8px 10px;
border-radius: 6px;
}
.msg {
display: flex;
}
.msg.user { justify-content: flex-end; }
.msg.assistant { justify-content: flex-start; }
.msg-bubble {
max-width: 84%;
padding: 8px 12px;
border-radius: 12px;
font-size: 13px;
line-height: 1.55;
white-space: pre-wrap;
word-break: break-word;
}
.msg.user .msg-bubble {
background: var(--accent-strong);
color: #fff;
border-bottom-right-radius: 3px;
}
.msg.assistant .msg-bubble {
background: var(--bg-card);
color: var(--text);
border-bottom-left-radius: 3px;
}
.chat-input {
border-top: 1px solid var(--border-soft);
padding: 10px;
display: flex;
gap: 8px;
align-items: end;
}
.chat-input textarea {
flex: 1;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 6px;
padding: 8px 10px;
font-size: 13px;
line-height: 1.5;
resize: none;
color: var(--text);
}
.chat-input textarea:focus { border-color: var(--accent-strong); outline: none; }
.chat-send {
width: 36px;
height: 36px;
border-radius: 50%;
background: var(--accent-strong);
color: #fff;
font-size: 16px;
font-weight: 600;
flex-shrink: 0;
}
.chat-send:hover:not(:disabled) { background: var(--accent); }
@media (max-width: 768px) {
.main { flex-direction: column; }
.sidebar { width: 100%; height: 38vh; min-height: 200px; border-right: none; border-bottom: 1px solid var(--border-soft); }
.sidebar.has-selected { height: 30vh; }
}
</style>