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
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:
@@ -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 Loop(rate 保留全局)
|
||||
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);
|
||||
|
||||
Reference in New Issue
Block a user