Files
cube/apps/music/frontend/src/views/PlayerView.vue
T
Fam Zheng cdbf8308d1
deploy articulate / build-and-deploy (push) Failing after 1m42s
deploy cube / build-and-deploy (push) Successful in 2m5s
deploy karaoke / build-and-deploy (push) Failing after 2m2s
deploy simpleasm / build-and-deploy (push) Successful in 2m21s
deploy music / build-and-deploy (push) Successful in 4m2s
deploy werewolf / build-and-deploy (push) Failing after 58s
music(player): 变速播放 + AB Loop
- 变速:底部 1× 圆形按钮循环切 0.5/0.75/1/1.25/1.5;preservesPitch=true(浏览器 native 保音高);localStorage 持久化全局
- AB Loop:A B 两按钮在当前位置打点,🔁 开关;进度条上绿色高亮 A↔B 区段;timeupdate 触发 ≥B 跳回 A;切歌自动清 A/B
2026-05-10 21:40:19 +01:00

1879 lines
51 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="activeTab === 'chord'" class="sheet-box">
<img
v-for="att in chordTabAttachments('chord')"
:key="att.id"
:src="attachmentUrl(att.id)"
:alt="att.filename"
class="sheet-img"
@click="fullscreenSrc = attachmentUrl(att.id)"
/>
<div
v-if="chordTabAttachments('chord').length === 0"
class="auto-fetch"
>
<p v-if="chordStateOf('chord') === 'idle'" class="hint-line">
yopu.co <b>和弦谱歌词 + 数字级数 1/4/5/6m</b>
</p>
<p v-else-if="['pending','processing'].includes(chordStateOf('chord'))" class="hint-line">
正在抓取 30-60s
</p>
<p v-else-if="chordStateOf('chord') === 'failed'" class="hint-line err">
抓取失败{{ chordErrors.chord }}
</p>
<button
class="btn-fetch"
:disabled="['pending','processing'].includes(chordStateOf('chord'))"
@click="startChordFetch('chord')"
>
<span v-if="['pending','processing'].includes(chordStateOf('chord'))" class="spin"></span>
<span v-else>🎸 自动抓取和弦谱</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
v-if="loopA != null"
class="loop-mark"
:style="{ left: progressForTime(loopA) + '%' }"
title="A"
>A</div>
<div
v-if="loopB != null"
class="loop-mark"
:style="{ left: progressForTime(loopB) + '%' }"
title="B"
>B</div>
<div
v-if="loopA != null && loopB != null"
class="loop-range"
:style="loopRangeStyle"
></div>
<div class="fill" :style="{ width: progressPct + '%' }"></div>
</div>
<span class="time">{{ fmtTime(duration) }}</span>
<button class="ab-btn" :class="{ on: loopA != null }" title="A 点" @click="setA">A</button>
<button class="ab-btn" :class="{ on: loopB != null }" title="B 点" @click="setB">B</button>
<button
class="ab-btn"
:class="{ on: loopOn }"
:disabled="loopA == null || loopB == null"
title="A↔B 循环"
@click="toggleLoop"
>🔁</button>
<button v-if="loopA != null || loopB != null" class="ab-btn clear" title="清掉 A/B" @click="clearLoop"></button>
<button class="rate-btn" :title="`变速 ${rateLabel}`" @click="cycleRate">{{ rateLabel }}</button>
<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')
// 变速播放(保留 0.5/0.75/1/1.25/1.5 五档;浏览器 native preserve pitch 在 macOS Safari/Chrome 默认开)
const rateOptions = [0.5, 0.75, 1, 1.25, 1.5]
const playbackRate = ref(parseFloat(localStorage.getItem('music.rate')) || 1)
const rateLabel = computed(() => {
const r = playbackRate.value
return r === 1 ? '1×' : (r % 1 === 0 ? r + '×' : r.toFixed(2).replace(/0$/, '') + '×')
})
function cycleRate() {
const idx = rateOptions.indexOf(playbackRate.value)
const next = rateOptions[(idx + 1) % rateOptions.length]
playbackRate.value = next
localStorage.setItem('music.rate', String(next))
if (audioEl.value) {
audioEl.value.playbackRate = next
audioEl.value.preservesPitch = true
}
}
// AB Loop
const loopA = ref(null)
const loopB = ref(null)
const loopOn = ref(false)
function setA() {
if (!audioEl.value) return
loopA.value = audioEl.value.currentTime
if (loopB.value != null && loopB.value <= loopA.value) loopB.value = null
}
function setB() {
if (!audioEl.value) return
const t = audioEl.value.currentTime
if (loopA.value != null && t > loopA.value) {
loopB.value = t
loopOn.value = true
} else {
// A 没设或 t<=A:忽略
}
}
function toggleLoop() {
if (loopA.value != null && loopB.value != null) loopOn.value = !loopOn.value
}
function clearLoop() {
loopA.value = null
loopB.value = null
loopOn.value = false
}
function progressForTime(t) {
if (!duration.value) return 0
return Math.max(0, Math.min(100, (t / duration.value) * 100))
}
const loopRangeStyle = computed(() => {
if (loopA.value == null || loopB.value == null || !duration.value) return {}
const left = progressForTime(loopA.value)
const right = progressForTime(loopB.value)
return { left: left + '%', width: (right - left) + '%' }
})
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(只抓 yopu 默认数字级数版)
const chordStates = ref({ chord: 'idle' })
const chordErrors = ref({ chord: '' })
const chordPollTimers = { chord: null }
const chordPollStarted = { chord: 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,
)
}
// 和弦谱 tab = role='chord'(数字级数版,yopu 自动抓的就这种)
function chordTabAttachments(tab) {
return (selected.value?.attachments || []).filter(
a => a.kind === 'image' && a.role === 'chord',
)
}
function modeForTab(_tab) {
return 'chord' // 唯一抓取模式
}
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 })
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 = ''
// 切歌清 AB Looprate 保留全局)
clearLoop()
stopChordPoll('chord')
chordStates.value = { chord: 'idle' }
chordErrors.value = { chord: '' }
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
// AB Loop:到 B 点跳回 A
if (loopOn.value && loopA.value != null && loopB.value != null
&& currentTime.value >= loopB.value) {
e.target.currentTime = loopA.value
}
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()
e.target.playbackRate = playbackRate.value
e.target.preservesPitch = true
}
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('chord')
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;
pointer-events: none;
}
.loop-range {
position: absolute;
top: 0;
height: 100%;
background: rgba(74, 222, 128, 0.35);
border-radius: 2px;
pointer-events: none;
}
.loop-mark {
position: absolute;
top: -8px;
transform: translateX(-50%);
font-size: 9px;
color: var(--accent-green);
background: rgba(74, 222, 128, 0.2);
padding: 1px 4px;
border-radius: 3px;
pointer-events: none;
font-weight: 700;
}
.ab-btn {
font-size: 11px;
padding: 4px 8px;
border-radius: 4px;
background: var(--bg-elev);
color: var(--text-mute);
border: 1px solid var(--border);
min-width: 28px;
height: 28px;
}
.ab-btn:hover:not(:disabled) { color: var(--text); }
.ab-btn.on {
background: rgba(74, 222, 128, 0.18);
color: var(--accent-green);
border-color: var(--accent-green);
}
.ab-btn.clear { color: var(--accent-red); border-color: rgba(239,68,68,0.4); }
.rate-btn {
font-size: 12px;
padding: 4px 10px;
border-radius: 14px;
background: var(--bg-elev);
color: var(--text-dim);
border: 1px solid var(--border);
min-width: 44px;
height: 28px;
font-weight: 600;
}
.rate-btn:hover { color: var(--accent); }
.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>