2026-05-09 22:36:14 +01:00
|
|
|
|
<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>
|
|
|
|
|
|
<router-link to="/upload" class="btn-add" title="新增曲目">+</router-link>
|
|
|
|
|
|
</header>
|
|
|
|
|
|
|
2026-05-10 14:51:53 +01:00
|
|
|
|
<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>
|
|
|
|
|
|
|
2026-05-09 22:36:14 +01:00
|
|
|
|
<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">
|
2026-05-10 14:51:53 +01:00
|
|
|
|
空空如也<span v-if="activePlaylistId || activeTagName">(当前筛选下)</span>。
|
2026-05-09 22:36:14 +01:00
|
|
|
|
</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>
|
2026-05-10 14:51:53 +01:00
|
|
|
|
<span v-for="tg in p.tags" :key="tg" class="tg">#{{ tg }}</span>
|
2026-05-09 22:36:14 +01:00
|
|
|
|
</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>
|
2026-05-10 14:51:53 +01:00
|
|
|
|
<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>
|
2026-05-09 22:36:14 +01:00
|
|
|
|
</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>
|
|
|
|
|
|
|
2026-05-10 14:51:53 +01:00
|
|
|
|
<!-- 谱面 -->
|
2026-05-09 22:36:14 +01:00
|
|
|
|
<div v-show="['chord', '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"
|
2026-05-10 14:59:07 +01:00
|
|
|
|
@click="fullscreenSrc = attachmentUrl(att.id)"
|
2026-05-09 22:36:14 +01:00
|
|
|
|
/>
|
2026-05-09 22:52:09 +01:00
|
|
|
|
<div
|
|
|
|
|
|
v-if="activeTab === 'chord' && roleAttachments('chord').length === 0"
|
|
|
|
|
|
class="auto-fetch"
|
|
|
|
|
|
>
|
|
|
|
|
|
<p v-if="chordState === 'idle'" class="hint-line">
|
|
|
|
|
|
从 yopu.co 抓 <b>功能谱 + 级数名</b>。
|
|
|
|
|
|
</p>
|
|
|
|
|
|
<p v-else-if="chordState === 'pending' || chordState === 'processing'" class="hint-line">
|
|
|
|
|
|
正在抓取,浏览器后台跑 chromium 截图,约 30-60s…
|
|
|
|
|
|
</p>
|
|
|
|
|
|
<p v-else-if="chordState === 'failed'" class="hint-line err">
|
|
|
|
|
|
抓取失败:{{ chordError }}
|
|
|
|
|
|
</p>
|
|
|
|
|
|
<button
|
|
|
|
|
|
class="btn-fetch"
|
|
|
|
|
|
:disabled="chordState === 'pending' || chordState === 'processing'"
|
|
|
|
|
|
@click="startChordFetch"
|
|
|
|
|
|
>
|
|
|
|
|
|
<span v-if="chordState === 'pending' || chordState === 'processing'" class="spin">⏳</span>
|
|
|
|
|
|
<span v-else>🎸 自动抓取吉他谱</span>
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
2026-05-09 22:36:14 +01:00
|
|
|
|
</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>
|
2026-05-10 14:51:53 +01:00
|
|
|
|
|
|
|
|
|
|
<!-- 笔记(独立 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>
|
2026-05-09 22:36:14 +01:00
|
|
|
|
</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>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</footer>
|
|
|
|
|
|
|
|
|
|
|
|
<audio
|
|
|
|
|
|
ref="audioEl"
|
|
|
|
|
|
@timeupdate="onTimeUpdate"
|
|
|
|
|
|
@loadedmetadata="onLoaded"
|
|
|
|
|
|
@ended="onEnded"
|
|
|
|
|
|
@play="playing = true"
|
|
|
|
|
|
@pause="playing = false"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
</section>
|
|
|
|
|
|
|
2026-05-10 14:59:07 +01:00
|
|
|
|
<!-- 全屏乐谱 overlay:再点一下关闭,或按 ESC -->
|
|
|
|
|
|
<div
|
|
|
|
|
|
v-if="fullscreenSrc"
|
|
|
|
|
|
class="fs-overlay"
|
|
|
|
|
|
@click="fullscreenSrc = null"
|
|
|
|
|
|
>
|
|
|
|
|
|
<img :src="fullscreenSrc" class="fs-img" />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-05-10 14:51:53 +01:00
|
|
|
|
<!-- 右侧 LLM chat 边栏 -->
|
|
|
|
|
|
<aside v-if="selected" class="chat">
|
|
|
|
|
|
<header class="chat-head">
|
2026-05-10 14:57:36 +01:00
|
|
|
|
<span>Chat · {{ selected.title.slice(0, 16) }}</span>
|
2026-05-10 14:51:53 +01:00
|
|
|
|
<button class="chat-clear" @click="onChatClear" title="清空对话">清空</button>
|
2026-05-09 22:36:14 +01:00
|
|
|
|
</header>
|
2026-05-10 14:51:53 +01:00
|
|
|
|
<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>
|
2026-05-09 22:36:14 +01:00
|
|
|
|
</aside>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
|
|
<script setup>
|
|
|
|
|
|
import { ref, computed, onMounted, onBeforeUnmount, watch, nextTick } from 'vue'
|
|
|
|
|
|
import { useRoute, useRouter } from 'vue-router'
|
|
|
|
|
|
import {
|
|
|
|
|
|
listPieces,
|
2026-05-10 14:51:53 +01:00
|
|
|
|
listPlaylists,
|
|
|
|
|
|
listTags,
|
|
|
|
|
|
createPlaylist,
|
2026-05-09 22:36:14 +01:00
|
|
|
|
getPiece,
|
|
|
|
|
|
patchPiece,
|
|
|
|
|
|
recordPlay,
|
|
|
|
|
|
attachmentUrl as attUrl,
|
2026-05-09 22:52:09 +01:00
|
|
|
|
chordFetch,
|
|
|
|
|
|
chordStatus,
|
2026-05-10 14:51:53 +01:00
|
|
|
|
listChat,
|
|
|
|
|
|
clearChat,
|
|
|
|
|
|
streamChat,
|
2026-05-09 22:36:14 +01:00
|
|
|
|
} 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)
|
|
|
|
|
|
|
2026-05-10 14:51:53 +01:00
|
|
|
|
const playlists = ref([])
|
|
|
|
|
|
const tagsList = ref([])
|
|
|
|
|
|
const activePlaylistId = ref(null)
|
|
|
|
|
|
const activeTagName = ref(null)
|
|
|
|
|
|
|
2026-05-09 22:36:14 +01:00
|
|
|
|
const search = ref('')
|
|
|
|
|
|
const sortMode = ref(localStorage.getItem('music.sort') || 'name')
|
|
|
|
|
|
const repeatOne = ref(false)
|
|
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
|
2026-05-10 14:59:07 +01:00
|
|
|
|
// fullscreen 乐谱
|
|
|
|
|
|
const fullscreenSrc = ref(null)
|
|
|
|
|
|
|
2026-05-10 14:51:53 +01:00
|
|
|
|
// chord
|
|
|
|
|
|
const chordState = ref('idle')
|
2026-05-09 22:52:09 +01:00
|
|
|
|
const chordError = ref('')
|
|
|
|
|
|
let chordPollTimer = null
|
|
|
|
|
|
let chordPollStarted = 0
|
|
|
|
|
|
|
2026-05-10 14:51:53 +01:00
|
|
|
|
// 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
|
|
|
|
|
|
|
2026-05-09 22:36:14 +01:00
|
|
|
|
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,
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const tabs = computed(() => {
|
|
|
|
|
|
if (!selected.value) return []
|
|
|
|
|
|
const list = []
|
|
|
|
|
|
if (selected.value.lyrics) list.push({ key: 'lyrics', label: '歌词', count: 0 })
|
2026-05-09 22:52:09 +01:00
|
|
|
|
list.push({ key: 'chord', label: '吉他谱', count: roleAttachments('chord').length })
|
2026-05-09 22:36:14 +01:00
|
|
|
|
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 })
|
2026-05-10 14:51:53 +01:00
|
|
|
|
list.push({ key: 'notes', label: '笔记', count: 0 })
|
2026-05-09 22:36:14 +01:00
|
|
|
|
return list
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
const filtered = computed(() => {
|
|
|
|
|
|
const q = search.value.trim().toLowerCase()
|
|
|
|
|
|
let arr = pieces.value
|
|
|
|
|
|
if (q) {
|
|
|
|
|
|
arr = arr.filter(p => {
|
2026-05-10 14:51:53 +01:00
|
|
|
|
const hay = `${p.title} ${p.artist || ''} ${p.category || ''} ${(p.tags || []).join(' ')}`.toLowerCase()
|
2026-05-09 22:36:14 +01:00
|
|
|
|
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': {
|
2026-05-10 14:51:53 +01:00
|
|
|
|
const seeded = arr.map(p => ({ p, k: hash(p.id, randomSeed) }))
|
2026-05-09 22:36:14 +01:00
|
|
|
|
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 {
|
2026-05-10 14:51:53 +01:00
|
|
|
|
pieces.value = await listPieces({
|
|
|
|
|
|
tag: activeTagName.value,
|
|
|
|
|
|
playlist: activePlaylistId.value,
|
|
|
|
|
|
})
|
2026-05-09 22:36:14 +01:00
|
|
|
|
} catch (e) {
|
|
|
|
|
|
loadError.value = e.message || String(e)
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
loading.value = false
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-10 14:51:53 +01:00
|
|
|
|
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))
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-09 22:36:14 +01:00
|
|
|
|
async function loadPiece(id) {
|
|
|
|
|
|
selected.value = null
|
|
|
|
|
|
notesDraft.value = ''
|
2026-05-09 22:52:09 +01:00
|
|
|
|
stopChordPoll()
|
|
|
|
|
|
chordState.value = 'idle'
|
|
|
|
|
|
chordError.value = ''
|
2026-05-10 14:51:53 +01:00
|
|
|
|
abortChat()
|
|
|
|
|
|
chatMessages.value = []
|
|
|
|
|
|
chatStreamText.value = ''
|
|
|
|
|
|
chatError.value = ''
|
2026-05-09 22:36:14 +01:00
|
|
|
|
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
|
2026-05-10 14:51:53 +01:00
|
|
|
|
// 加载 chat 历史
|
|
|
|
|
|
loadChat(id)
|
2026-05-09 22:36:14 +01:00
|
|
|
|
} catch (e) {
|
|
|
|
|
|
loadError.value = e.message || String(e)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-10 14:51:53 +01:00
|
|
|
|
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
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-09 22:36:14 +01:00
|
|
|
|
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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-10 14:51:53 +01:00
|
|
|
|
// 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
|
2026-05-09 22:52:09 +01:00
|
|
|
|
async function startChordFetch() {
|
|
|
|
|
|
if (!selectedId.value) return
|
|
|
|
|
|
chordState.value = 'pending'
|
|
|
|
|
|
chordError.value = ''
|
|
|
|
|
|
try {
|
|
|
|
|
|
const r = await chordFetch(selectedId.value)
|
|
|
|
|
|
if (r.status === 'completed') {
|
|
|
|
|
|
await reloadPiece()
|
|
|
|
|
|
chordState.value = 'completed'
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
chordState.value = r.status || 'pending'
|
|
|
|
|
|
chordPollStarted = Date.now()
|
|
|
|
|
|
if (chordPollTimer) clearInterval(chordPollTimer)
|
|
|
|
|
|
chordPollTimer = setInterval(pollChord, 3000)
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
chordState.value = 'failed'
|
|
|
|
|
|
chordError.value = e.message || String(e)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function pollChord() {
|
|
|
|
|
|
if (!selectedId.value) { stopChordPoll(); return }
|
|
|
|
|
|
if (Date.now() - chordPollStarted > 90_000) {
|
|
|
|
|
|
stopChordPoll()
|
|
|
|
|
|
chordState.value = 'failed'
|
2026-05-10 14:51:53 +01:00
|
|
|
|
chordError.value = '抓取超时'
|
2026-05-09 22:52:09 +01:00
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
try {
|
|
|
|
|
|
const r = await chordStatus(selectedId.value)
|
|
|
|
|
|
chordState.value = r.status || 'pending'
|
|
|
|
|
|
chordError.value = r.error || ''
|
|
|
|
|
|
if (r.status === 'completed') {
|
|
|
|
|
|
stopChordPoll()
|
|
|
|
|
|
await reloadPiece()
|
|
|
|
|
|
} else if (r.status === 'failed') {
|
|
|
|
|
|
stopChordPoll()
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
chordError.value = e.message || String(e)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function stopChordPoll() {
|
|
|
|
|
|
if (chordPollTimer) {
|
|
|
|
|
|
clearInterval(chordPollTimer)
|
|
|
|
|
|
chordPollTimer = null
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function reloadPiece() {
|
|
|
|
|
|
if (!selectedId.value) return
|
|
|
|
|
|
try {
|
|
|
|
|
|
const fresh = await getPiece(selectedId.value)
|
|
|
|
|
|
selected.value = fresh
|
|
|
|
|
|
} catch {}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-10 14:51:53 +01:00
|
|
|
|
// chat
|
|
|
|
|
|
async function onChatSend() {
|
|
|
|
|
|
const msg = chatDraft.value.trim()
|
|
|
|
|
|
if (!msg || chatStreaming.value || !selectedId.value) return
|
|
|
|
|
|
// 乐观追加 user msg(assistant 流完整结束后会从 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
|
2026-05-09 22:36:14 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// keyboard
|
|
|
|
|
|
function onKeyDown(e) {
|
2026-05-10 14:59:07 +01:00
|
|
|
|
// ESC 在任何输入态下都能关全屏
|
|
|
|
|
|
if (e.key === 'Escape' && fullscreenSrc.value) {
|
|
|
|
|
|
e.preventDefault()
|
|
|
|
|
|
fullscreenSrc.value = null
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
2026-05-09 22:36:14 +01:00
|
|
|
|
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)
|
2026-05-10 14:51:53 +01:00
|
|
|
|
await Promise.all([loadPieces(), loadPlaylists(), loadTags()])
|
2026-05-09 22:36:14 +01:00
|
|
|
|
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)
|
2026-05-09 22:52:09 +01:00
|
|
|
|
stopChordPoll()
|
2026-05-10 14:51:53 +01:00
|
|
|
|
abortChat()
|
2026-05-09 22:36:14 +01:00
|
|
|
|
})
|
|
|
|
|
|
</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; }
|
|
|
|
|
|
.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; }
|
|
|
|
|
|
|
2026-05-10 14:51:53 +01:00
|
|
|
|
.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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-09 22:36:14 +01:00
|
|
|
|
.main {
|
|
|
|
|
|
flex: 1;
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
overflow: hidden;
|
|
|
|
|
|
min-height: 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.sidebar {
|
2026-05-10 14:51:53 +01:00
|
|
|
|
width: 320px;
|
|
|
|
|
|
min-width: 260px;
|
2026-05-09 22:36:14 +01:00
|
|
|
|
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;
|
2026-05-10 14:51:53 +01:00
|
|
|
|
flex-wrap: wrap;
|
2026-05-09 22:36:14 +01:00
|
|
|
|
}
|
|
|
|
|
|
.row-meta .cat {
|
|
|
|
|
|
color: var(--accent-cyan);
|
|
|
|
|
|
background: rgba(6, 182, 212, 0.1);
|
|
|
|
|
|
padding: 0 6px;
|
|
|
|
|
|
border-radius: 8px;
|
|
|
|
|
|
}
|
2026-05-10 14:51:53 +01:00
|
|
|
|
.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; }
|
2026-05-09 22:36:14 +01:00
|
|
|
|
|
|
|
|
|
|
.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 {
|
2026-05-10 14:51:53 +01:00
|
|
|
|
padding: 16px 24px 6px;
|
2026-05-09 22:36:14 +01:00
|
|
|
|
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;
|
2026-05-10 14:59:07 +01:00
|
|
|
|
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;
|
2026-05-09 22:36:14 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-09 22:52:09 +01:00
|
|
|
|
.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); } }
|
|
|
|
|
|
|
2026-05-09 22:36:14 +01:00
|
|
|
|
.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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-10 14:51:53 +01:00
|
|
|
|
.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); }
|
|
|
|
|
|
|
2026-05-09 22:36:14 +01:00
|
|
|
|
.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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-10 14:51:53 +01:00
|
|
|
|
/* Chat sidebar */
|
|
|
|
|
|
.chat {
|
|
|
|
|
|
width: 320px;
|
2026-05-09 22:36:14 +01:00
|
|
|
|
border-left: 1px solid var(--border-soft);
|
|
|
|
|
|
background: var(--bg-elev);
|
|
|
|
|
|
display: none;
|
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
|
flex-shrink: 0;
|
|
|
|
|
|
}
|
2026-05-10 14:51:53 +01:00
|
|
|
|
@media (min-width: 1280px) {
|
|
|
|
|
|
.chat { display: flex; }
|
|
|
|
|
|
}
|
|
|
|
|
|
.chat-head {
|
|
|
|
|
|
padding: 10px 14px;
|
2026-05-09 22:36:14 +01:00
|
|
|
|
font-size: 12px;
|
|
|
|
|
|
color: var(--text-mute);
|
|
|
|
|
|
border-bottom: 1px solid var(--border-soft);
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
justify-content: space-between;
|
2026-05-10 14:51:53 +01:00
|
|
|
|
align-items: center;
|
2026-05-09 22:36:14 +01:00
|
|
|
|
}
|
2026-05-10 14:51:53 +01:00
|
|
|
|
.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 {
|
2026-05-09 22:36:14 +01:00
|
|
|
|
flex: 1;
|
2026-05-10 14:51:53 +01:00
|
|
|
|
overflow-y: auto;
|
|
|
|
|
|
padding: 12px 12px 8px;
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
|
gap: 8px;
|
|
|
|
|
|
}
|
|
|
|
|
|
.chat-empty {
|
|
|
|
|
|
color: var(--text-mute);
|
2026-05-09 22:36:14 +01:00
|
|
|
|
font-size: 13px;
|
2026-05-10 14:51:53 +01:00
|
|
|
|
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;
|
2026-05-09 22:36:14 +01:00
|
|
|
|
resize: none;
|
2026-05-10 14:51:53 +01:00
|
|
|
|
color: var(--text);
|
2026-05-09 22:36:14 +01:00
|
|
|
|
}
|
2026-05-10 14:51:53 +01:00
|
|
|
|
.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;
|
2026-05-09 22:36:14 +01:00
|
|
|
|
}
|
2026-05-10 14:51:53 +01:00
|
|
|
|
.chat-send:hover:not(:disabled) { background: var(--accent); }
|
2026-05-09 22:36:14 +01:00
|
|
|
|
|
|
|
|
|
|
@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>
|