music(player): 变速播放 + AB Loop
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

- 变速:底部 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
This commit is contained in:
Fam Zheng
2026-05-10 21:40:19 +01:00
parent 5674be1cfd
commit cdbf8308d1
81 changed files with 5899 additions and 0 deletions
@@ -227,9 +227,37 @@
<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 ? '取消静音' : '静音'"
@@ -380,6 +408,62 @@ 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 '🔈'
@@ -658,6 +742,8 @@ async function promptNewPlaylist() {
async function loadPiece(id) {
selected.value = null
notesDraft.value = ''
// 切歌清 AB Looprate 保留全局)
clearLoop()
stopChordPoll('chord')
chordStates.value = { chord: 'idle' }
chordErrors.value = { chord: '' }
@@ -745,6 +831,11 @@ function seekBar(e) {
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 => {
@@ -774,6 +865,8 @@ function onTimeUpdate(e) {
function onLoaded(e) {
duration.value = e.target.duration || 0
applyVolume()
e.target.playbackRate = playbackRate.value
e.target.preservesPitch = true
}
function onEnded() {
@@ -1578,7 +1671,58 @@ onBeforeUnmount(() => {
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);