music(pwa): PWA + 可选离线缓存全库(IndexedDB),默认关
deploy music / build-and-deploy (push) Successful in 2m1s

- vite-plugin-pwa injectManifest 模式,自定义 sw.js precache app shell
- manifest 支持加桌面 + standalone(icon 暂借 werewolf 紫色调,后续换)
- src/lib/cache.js IDB 缓存层:audio + 谱面 PNG 单 attachment id 存放,blob URL 复用
- 启动 initCache 按 localStorage 'music.cache.enabled' 决定是否后台开始下载
- 后台 worker:串行 concurrency=2 + 80ms 间隔,仅 WiFi 时跑(默认)
- audio src 优先走 IDB blob URL,没缓存才走网络
- /settings 配置页:开关 + 仅 WiFi 切换 + 进度条 + 用量/quota + 清空缓存
- topbar 加 ⚙ 按钮

默认关,首次明确 prompt-by-checkbox 才开。整库 ~1.5GB。
This commit is contained in:
Fam Zheng
2026-05-25 22:09:54 +01:00
parent bcf99ec454
commit 8991033f70
15 changed files with 5482 additions and 25 deletions
+4851 -18
View File
File diff suppressed because it is too large Load Diff
+4 -1
View File
@@ -14,6 +14,9 @@
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.0.4",
"vite": "^5.4.0"
"vite": "^5.4.0",
"vite-plugin-pwa": "^0.20.5",
"workbox-build": "^7.1.0",
"workbox-window": "^7.1.0"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 336 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 558 KiB

+264
View File
@@ -0,0 +1,264 @@
// 离线缓存 — pieces metadata + audio + chord PNG 都存 IndexedDB。
// 不用 Cache API 是因为 IDB 单条删除可控,大文件 Blob 友好。
//
// 配置(localStorage):
// music.cache.enabled 'true' | 'false' 默认 false
// music.cache.wifiOnly 'true' | 'false' 默认 true
//
// 进度向 window 广播 CustomEvent('music-cache-progress', { detail: { done, total, busy } })
import { ref } from 'vue'
import { listPieces, getPiece, attachmentUrl } from './api.js'
const DB_NAME = 'music-cache'
const DB_VERSION = 1
const STORE_AUDIO = 'audio' // key: attachment id (number)
const STORE_IMAGE = 'image' // key: attachment id
const STORE_META = 'meta' // key: 'pieces' | 'updated_at' ...
let dbPromise = null
function openDb() {
if (dbPromise) return dbPromise
dbPromise = new Promise((resolve, reject) => {
const req = indexedDB.open(DB_NAME, DB_VERSION)
req.onupgradeneeded = () => {
const db = req.result
if (!db.objectStoreNames.contains(STORE_AUDIO)) db.createObjectStore(STORE_AUDIO)
if (!db.objectStoreNames.contains(STORE_IMAGE)) db.createObjectStore(STORE_IMAGE)
if (!db.objectStoreNames.contains(STORE_META)) db.createObjectStore(STORE_META)
}
req.onsuccess = () => resolve(req.result)
req.onerror = () => reject(req.error)
})
return dbPromise
}
async function idbGet(store, key) {
const db = await openDb()
return new Promise((resolve, reject) => {
const tx = db.transaction(store, 'readonly')
const req = tx.objectStore(store).get(key)
req.onsuccess = () => resolve(req.result)
req.onerror = () => reject(req.error)
})
}
async function idbPut(store, key, value) {
const db = await openDb()
return new Promise((resolve, reject) => {
const tx = db.transaction(store, 'readwrite')
tx.objectStore(store).put(value, key)
tx.oncomplete = () => resolve()
tx.onerror = () => reject(tx.error)
})
}
async function idbDelete(store, key) {
const db = await openDb()
return new Promise((resolve, reject) => {
const tx = db.transaction(store, 'readwrite')
tx.objectStore(store).delete(key)
tx.oncomplete = () => resolve()
tx.onerror = () => reject(tx.error)
})
}
async function idbKeys(store) {
const db = await openDb()
return new Promise((resolve, reject) => {
const tx = db.transaction(store, 'readonly')
const req = tx.objectStore(store).getAllKeys()
req.onsuccess = () => resolve(req.result)
req.onerror = () => reject(req.error)
})
}
async function idbClearAll() {
const db = await openDb()
return new Promise((resolve, reject) => {
const tx = db.transaction([STORE_AUDIO, STORE_IMAGE, STORE_META], 'readwrite')
tx.objectStore(STORE_AUDIO).clear()
tx.objectStore(STORE_IMAGE).clear()
tx.objectStore(STORE_META).clear()
tx.oncomplete = () => resolve()
tx.onerror = () => reject(tx.error)
})
}
// ---- 配置 ----
export function isCacheEnabled() {
return localStorage.getItem('music.cache.enabled') === 'true'
}
export function setCacheEnabled(v) {
localStorage.setItem('music.cache.enabled', v ? 'true' : 'false')
}
export function isWifiOnly() {
return localStorage.getItem('music.cache.wifiOnly') !== 'false' // 默认 true
}
export function setWifiOnly(v) {
localStorage.setItem('music.cache.wifiOnly', v ? 'true' : 'false')
}
// 网络感知:不是 WiFi(蜂窝 / 慢速)就跳过
function isLikelyMetered() {
const c = navigator.connection || navigator.mozConnection || navigator.webkitConnection
if (!c) return false // 不知道就当 OK
if (c.saveData) return true
if (c.type === 'cellular') return true
if (['slow-2g', '2g'].includes(c.effectiveType)) return true
return false
}
// ---- 媒体取用 ----
const blobUrlCache = new Map() // attId -> blob URL(避免反复 createObjectURL
export async function getCachedBlobUrl(store, attId) {
if (blobUrlCache.has(attId)) return blobUrlCache.get(attId)
const blob = await idbGet(store, attId)
if (!blob) return null
const url = URL.createObjectURL(blob)
blobUrlCache.set(attId, url)
return url
}
export async function getAudioUrl(attId) {
const cached = await getCachedBlobUrl(STORE_AUDIO, attId)
return cached || attachmentUrl(attId)
}
export async function getImageUrl(attId) {
const cached = await getCachedBlobUrl(STORE_IMAGE, attId)
return cached || attachmentUrl(attId)
}
// ---- 状态 ----
export const cacheStats = ref({ audioCount: 0, imageCount: 0, busy: false, done: 0, total: 0 })
async function refreshStats() {
const [a, i] = await Promise.all([idbKeys(STORE_AUDIO), idbKeys(STORE_IMAGE)])
cacheStats.value.audioCount = a.length
cacheStats.value.imageCount = i.length
}
function emitProgress() {
window.dispatchEvent(new CustomEvent('music-cache-progress', { detail: { ...cacheStats.value } }))
}
// ---- 下载 worker ----
let workerRunning = false
let workerAbort = null
async function downloadOne(url, store, key) {
const r = await fetch(url, { cache: 'reload' })
if (!r.ok) throw new Error(`${r.status}`)
const blob = await r.blob()
await idbPut(store, key, blob)
}
export async function startCacheWorker() {
if (workerRunning) return
if (!isCacheEnabled()) return
if (isWifiOnly() && isLikelyMetered()) {
console.log('[cache] skip: on metered connection')
return
}
// 主动申请永久存储
if (navigator.storage && navigator.storage.persist) {
try { await navigator.storage.persist() } catch {}
}
workerRunning = true
cacheStats.value.busy = true
workerAbort = new AbortController()
try {
// 1) 拉全部 pieces metadata
const pieces = await listPieces()
await idbPut(STORE_META, 'pieces', pieces)
await idbPut(STORE_META, 'updated_at', Date.now())
// 2) 算出所有要下载的 (store, id) 列表
const haveAudio = new Set(await idbKeys(STORE_AUDIO))
const haveImage = new Set(await idbKeys(STORE_IMAGE))
const targets = []
for (const p of pieces) {
const detail = await getPiece(p.id)
for (const a of detail.attachments || []) {
if (a.kind === 'audio' && !haveAudio.has(a.id)) {
targets.push({ store: STORE_AUDIO, id: a.id })
} else if (a.kind === 'image' && !haveImage.has(a.id)) {
targets.push({ store: STORE_IMAGE, id: a.id })
}
}
if (workerAbort.signal.aborted) break
}
cacheStats.value.total = targets.length
cacheStats.value.done = 0
emitProgress()
// 3) 串行下载(concurrency=2
const concurrency = 2
const queue = [...targets]
async function worker() {
while (queue.length && !workerAbort.signal.aborted) {
if (isWifiOnly() && isLikelyMetered()) break
const t = queue.shift()
try {
await downloadOne(attachmentUrl(t.id), t.store, t.id)
} catch (e) {
console.warn('[cache] dl failed', t, e)
}
cacheStats.value.done++
emitProgress()
await new Promise((r) => setTimeout(r, 80))
}
}
await Promise.all(Array.from({ length: concurrency }, () => worker()))
} catch (e) {
console.warn('[cache] worker error', e)
} finally {
workerRunning = false
cacheStats.value.busy = false
workerAbort = null
await refreshStats()
emitProgress()
}
}
export function abortCacheWorker() {
if (workerAbort) workerAbort.abort()
}
export async function clearCache() {
abortCacheWorker()
for (const u of blobUrlCache.values()) URL.revokeObjectURL(u)
blobUrlCache.clear()
await idbClearAll()
await refreshStats()
emitProgress()
}
// ---- 存储用量 ----
export async function estimateUsage() {
if (navigator.storage && navigator.storage.estimate) {
const e = await navigator.storage.estimate()
return { usage: e.usage || 0, quota: e.quota || 0 }
}
return { usage: 0, quota: 0 }
}
// ---- 启动入口 ----
export async function initCache() {
await refreshStats()
if (isCacheEnabled()) {
// 不阻塞主线程,延后 3s 让 app 先 render
setTimeout(() => startCacheWorker(), 3000)
}
}
+6
View File
@@ -2,8 +2,14 @@ import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import router from './router/index.js'
import { registerPwa } from './pwa.js'
import { initCache } from './lib/cache.js'
const app = createApp(App)
app.use(createPinia())
app.use(router)
app.mount('#app')
registerPwa()
initCache() // 启动时按配置决定是否后台缓存
+20
View File
@@ -0,0 +1,20 @@
// 注册 service worker(如果浏览器支持 + 不在 dev 模式下)。
// vite-plugin-pwa injectManifest 模式:build 后 dist/sw.js 是处理过的 worker。
import { registerSW } from 'virtual:pwa-register'
export function registerPwa() {
if (typeof window === 'undefined') return
if (!('serviceWorker' in navigator)) return
registerSW({
immediate: true,
onRegisteredSW(_swUrl, _r) {
console.log('[pwa] sw registered')
},
onOfflineReady() {
console.log('[pwa] offline ready')
},
onNeedRefresh() {
console.log('[pwa] update available')
},
})
}
+5
View File
@@ -23,6 +23,11 @@ const routes = [
component: () => import('../views/EditView.vue'),
props: (route) => ({ id: Number(route.params.id) }),
},
{
path: '/settings',
name: 'settings',
component: () => import('../views/SettingsView.vue'),
},
]
export default createRouter({
+75
View File
@@ -0,0 +1,75 @@
/// <reference lib="webworker" />
// Music PWA service workerinjectManifest 模式)。
// 只 precache app shellHTML/JS/CSS/icon),媒体(audio + chord PNG)走前端 IDB
// 显式缓存(lib/cache.js),不让 SW 自动 cache 大文件避免 quota 失控。
const MANIFEST = self.__WB_MANIFEST || []
function hashStr(s) {
let h = 5381
for (let i = 0; i < s.length; i++) h = ((h << 5) + h + s.charCodeAt(i)) >>> 0
return h.toString(36)
}
const VERSION = hashStr(MANIFEST.map((e) => `${e.url}@${e.revision || ''}`).join('|'))
const CACHE = `music-shell-${VERSION}`
const INDEX = new URL('index.html', self.location.href).href
const URLS = Array.from(new Set([INDEX, ...MANIFEST.map((e) => new URL(e.url, self.location.href).href)]))
self.addEventListener('install', (event) => {
event.waitUntil(
(async () => {
const cache = await caches.open(CACHE)
// 串行避免一次性请求过多
for (const u of URLS) {
try {
const r = await fetch(u, { cache: 'reload' })
if (r.ok) await cache.put(u, r)
} catch {}
}
await self.skipWaiting()
})(),
)
})
self.addEventListener('activate', (event) => {
event.waitUntil(
(async () => {
const keys = await caches.keys()
await Promise.all(
keys.filter((k) => k.startsWith('music-shell-') && k !== CACHE).map((k) => caches.delete(k)),
)
await self.clients.claim()
})(),
)
})
self.addEventListener('fetch', (event) => {
const req = event.request
if (req.method !== 'GET') return
const url = new URL(req.url)
// 只接管同源 GET,跳过 /api/*(前端 cache.js 自己管)
if (url.origin !== self.location.origin) return
if (url.pathname.startsWith('/api/')) return
event.respondWith(
(async () => {
const cache = await caches.open(CACHE)
// 导航请求总是回 index.html,让 SPA 路由跑(离线也能进任何 route)
if (req.mode === 'navigate') {
const cached = await cache.match(INDEX)
if (cached) return cached
}
const cached = await cache.match(req)
if (cached) return cached
try {
const fresh = await fetch(req)
if (fresh.ok) cache.put(req, fresh.clone()).catch(() => {})
return fresh
} catch (e) {
// 离线 + 没缓存
return new Response('offline', { status: 503 })
}
})(),
)
})
+12 -2
View File
@@ -11,6 +11,7 @@
<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>
<router-link to="/settings" class="btn-settings" title="设置"></router-link>
</header>
<nav class="filterbar">
@@ -386,6 +387,7 @@ import {
streamChat,
streamInspire,
} from '../lib/api.js'
import { getAudioUrl } from '../lib/cache.js'
import { parseLrc } from '../lib/lrc.js'
const route = useRoute()
@@ -771,8 +773,8 @@ async function loadPiece(id) {
await nextTick()
const first = audioAttachments.value[0]
if (first && audioEl.value) {
audioEl.value.src = attUrl(first.id)
// 只有上一首在播才自动续播;暂停状态 / 第一次进入 → 只设源,等用户点 ▶
// 优先用 IDB 缓存的 blob URL,没有再走网络
audioEl.value.src = await getAudioUrl(first.id)
if (wasPlaying) audioEl.value.play().catch(() => {})
} else if (audioEl.value) {
audioEl.value.removeAttribute('src')
@@ -1241,6 +1243,14 @@ onBeforeUnmount(() => {
text-decoration: none;
}
.topbar .btn-add:hover { background: var(--accent); text-decoration: none; }
.topbar .btn-settings {
width: 36px; height: 36px; border-radius: 50%;
background: var(--bg-elev); color: var(--text-dim);
display: inline-flex; align-items: center; justify-content: center;
font-size: 16px; text-decoration: none;
border: 1px solid var(--border);
}
.topbar .btn-settings:hover { background: var(--bg-hover); color: var(--text); text-decoration: none; }
.filterbar {
display: flex;
@@ -0,0 +1,212 @@
<template>
<div class="page">
<header class="bar">
<router-link to="/" class="back"> 返回</router-link>
<h1>设置</h1>
</header>
<main class="body">
<section class="block">
<h2>离线缓存</h2>
<p class="desc">
把所有曲目的 audio 和谱面 PNG 异步下载到浏览器 IndexedDB下载完没网也能播放看谱
默认 <b></b>整库约 1.5 GB移动网络下慎开
</p>
<label class="row">
<span class="lbl">启用自动缓存</span>
<input type="checkbox" v-model="enabled" @change="onEnabled" />
</label>
<label class="row" :class="{ disabled: !enabled }">
<span class="lbl"> WiFi 时下载</span>
<input type="checkbox" v-model="wifiOnly" :disabled="!enabled" @change="onWifi" />
</label>
<div class="stats">
<div class="stat">
<span class="key">已缓存 audio</span>
<span class="val">{{ stats.audioCount }} </span>
</div>
<div class="stat">
<span class="key">已缓存谱面</span>
<span class="val">{{ stats.imageCount }} </span>
</div>
<div class="stat">
<span class="key">磁盘占用</span>
<span class="val">{{ fmtMb(usage.usage) }} / {{ fmtMb(usage.quota) }}</span>
</div>
</div>
<div v-if="stats.busy" class="progress">
<div class="bar-bg">
<div class="bar-fill" :style="{ width: pct + '%' }"></div>
</div>
<div class="progress-text">下载中 {{ stats.done }} / {{ stats.total }} ({{ pct }}%)</div>
</div>
<div class="actions">
<button class="btn" :disabled="stats.busy || !enabled" @click="onStart">
{{ stats.busy ? '运行中' : '立即开始下载' }}
</button>
<button class="btn" :disabled="!stats.busy" @click="onStop">暂停</button>
<button class="btn danger" :disabled="stats.busy" @click="onClear">清空所有缓存</button>
</div>
</section>
<section class="block">
<h2>关于</h2>
<p class="desc">
PWA "加到主屏幕"作为独立 app 启动<br/>
数据用 IndexedDB可在浏览器开发者工具的 Application IndexedDB music-cache 看到详细条目<br/>
清空缓存只删本地数据不影响服务端
</p>
</section>
</main>
</div>
</template>
<script setup>
import { ref, onMounted, onBeforeUnmount } from 'vue'
import {
isCacheEnabled, setCacheEnabled,
isWifiOnly, setWifiOnly,
startCacheWorker, abortCacheWorker, clearCache,
estimateUsage, cacheStats,
} from '../lib/cache.js'
const enabled = ref(isCacheEnabled())
const wifiOnly = ref(isWifiOnly())
const stats = cacheStats
const usage = ref({ usage: 0, quota: 0 })
const pct = ref(0)
function onEnabled() {
setCacheEnabled(enabled.value)
if (enabled.value) startCacheWorker()
else abortCacheWorker()
}
function onWifi() {
setWifiOnly(wifiOnly.value)
}
function onStart() {
if (!enabled.value) {
enabled.value = true
setCacheEnabled(true)
}
startCacheWorker()
}
function onStop() {
abortCacheWorker()
}
async function onClear() {
if (!confirm('确认清空所有离线缓存?')) return
await clearCache()
await refreshUsage()
}
async function refreshUsage() {
usage.value = await estimateUsage()
pct.value = stats.value.total > 0
? Math.round((stats.value.done / stats.value.total) * 100)
: 0
}
function onProgress() {
pct.value = stats.value.total > 0
? Math.round((stats.value.done / stats.value.total) * 100)
: 0
refreshUsage()
}
onMounted(() => {
refreshUsage()
window.addEventListener('music-cache-progress', onProgress)
})
onBeforeUnmount(() => {
window.removeEventListener('music-cache-progress', onProgress)
})
function fmtMb(b) {
if (!b) return '?'
if (b < 1024 * 1024) return (b / 1024).toFixed(0) + ' KB'
if (b < 1024 * 1024 * 1024) return (b / 1024 / 1024).toFixed(1) + ' MB'
return (b / 1024 / 1024 / 1024).toFixed(2) + ' GB'
}
</script>
<style scoped>
.page { min-height: 100%; display: flex; flex-direction: column; background: var(--bg); }
.bar {
display: flex; align-items: center; gap: 18px;
padding: 14px 22px;
background: var(--bg-card);
border-bottom: 1px solid var(--border-soft);
}
.bar h1 { font-size: 18px; font-weight: 600; }
.back { color: var(--text-dim); font-size: 14px; }
.back:hover { color: var(--accent); text-decoration: none; }
.body { max-width: 720px; margin: 0 auto; padding: 24px 22px 80px; width: 100%; }
.block {
background: var(--bg-card);
border-radius: 10px;
padding: 20px;
margin-bottom: 18px;
}
.block h2 {
font-size: 14px;
color: var(--text-dim);
text-transform: uppercase;
letter-spacing: 0.06em;
font-weight: 600;
margin-bottom: 12px;
}
.desc { color: var(--text-dim); font-size: 13px; line-height: 1.7; margin-bottom: 14px; }
.desc b { color: var(--accent); }
.row {
display: flex; align-items: center; justify-content: space-between;
padding: 10px 0;
border-bottom: 1px solid var(--border-soft);
}
.row:last-of-type { border-bottom: none; }
.row.disabled { opacity: 0.5; }
.row .lbl { font-size: 14px; }
.row input[type=checkbox] { width: 18px; height: 18px; cursor: pointer; }
.stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 10px;
margin: 14px 0;
}
.stat {
background: var(--bg-elev);
border-radius: 6px;
padding: 10px 14px;
font-size: 12px;
}
.stat .key { color: var(--text-mute); display: block; margin-bottom: 4px; }
.stat .val { color: var(--text); font-weight: 600; }
.progress { margin: 14px 0; }
.bar-bg {
height: 6px; background: var(--bg-elev); border-radius: 3px; overflow: hidden;
}
.bar-fill {
height: 100%; background: var(--accent-strong); transition: width 0.3s;
}
.progress-text { font-size: 11px; color: var(--text-mute); margin-top: 6px; }
.actions { display: flex; gap: 10px; margin-top: 14px; flex-wrap: wrap; }
.btn {
font-size: 13px; padding: 8px 16px; border-radius: 6px;
background: var(--accent-strong); color: #fff; font-weight: 600;
}
.btn:hover:not(:disabled) { background: var(--accent); }
.btn:disabled { opacity: 0.4; cursor: not-allowed; }
.btn.danger { background: rgba(239,68,68,0.15); color: var(--accent-red); }
.btn.danger:hover:not(:disabled) { background: rgba(239,68,68,0.3); }
</style>
+33 -4
View File
@@ -1,11 +1,40 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { VitePWA } from 'vite-plugin-pwa'
export default defineConfig({
plugins: [vue()],
plugins: [
vue(),
VitePWA({
strategies: 'injectManifest',
srcDir: 'src',
filename: 'sw.js',
injectRegister: false, // 注册由 src/pwa.js 手动处理
injectManifest: {
globPatterns: ['**/*.{js,css,html,svg,png,woff2}'],
},
includeAssets: ['favicon-48x48.png', 'apple-touch-icon-180x180.png'],
manifest: {
name: 'Music · Euphon',
short_name: 'Music',
description: '听歌 + 练琴 曲目管理(离线缓存可选)',
lang: 'zh-CN',
theme_color: '#0f0f0f',
background_color: '#0f0f0f',
display: 'standalone',
start_url: '/',
scope: '/',
icons: [
{ src: 'pwa-192x192.png', sizes: '192x192', type: 'image/png' },
{ src: 'pwa-512x512.png', sizes: '512x512', type: 'image/png' },
{ src: 'maskable-icon-512x512.png', sizes: '512x512', type: 'image/png', purpose: 'maskable' },
],
},
}),
],
server: {
proxy: {
'/api': 'http://localhost:8080'
}
}
'/api': 'http://localhost:8080',
},
},
})