Skip to content

Commit 9476694

Browse files
committed
fix(ui): 修复歌单分屏闪烁与双轨 Cue 波形卡顿并发布预发布版
1 parent 0ccd793 commit 9476694

13 files changed

Lines changed: 380 additions & 61 deletions

‎package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "frkb",
3-
"version": "1.2.6-rc.202610281430",
3+
"version": "1.2.6-rc.202610281518",
44
"packageManager": "pnpm@9.15.9",
55
"description": "Track Studio — Rapid Audio Organization Tool",
66
"license": "PolyForm-Noncommercial-1.0.0",
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import { afterEach, describe, expect, it, vi } from 'vitest'
2+
import { createHorizontalBrowseStableRenderRevision } from './createHorizontalBrowseStableRenderRevision'
3+
import { useHorizontalBrowseWaveformPresentationCoordinator } from './horizontalBrowseWaveformPresentationCoordinator'
4+
import { createHorizontalBrowseStableCanvasPresentationController } from './horizontalBrowseStableCanvasPresentation'
5+
6+
afterEach(() => vi.unstubAllGlobals())
7+
8+
describe('Cue 跳转复用稳定波形', () => {
9+
it.each(['top', 'bottom'] as const)(
10+
'%s 反复返回 Cue 只移动已有画布,不让整片波形失效',
11+
(deck) => {
12+
vi.stubGlobal('requestAnimationFrame', () => 1)
13+
vi.stubGlobal('cancelAnimationFrame', () => {})
14+
const coordinator = useHorizontalBrowseWaveformPresentationCoordinator()
15+
const revision = createHorizontalBrowseStableRenderRevision({
16+
song: null,
17+
direction: deck === 'top' ? 'up' : 'down',
18+
get presentationState() {
19+
return coordinator.state[deck]
20+
}
21+
})
22+
const scheduleDraw = vi.fn()
23+
let playing = false
24+
const controller = createHorizontalBrowseStableCanvasPresentationController({
25+
isActive: () => true,
26+
isPlaying: () => playing,
27+
isDragging: () => false,
28+
currentSeconds: () => 8.615104,
29+
playbackRate: () => 1,
30+
renderRevision: () => revision.value,
31+
resolveViewportRangeStartSec: (seconds, visible = 12) => seconds - visible / 2,
32+
waveformCanvas: () => null,
33+
overlayCanvas: () => null,
34+
scheduleDraw
35+
})
36+
// 覆盖初始画布,以及曾拖动/调整后持有非零展示版本的画布。
37+
for (const afterDrag of [false, true]) {
38+
if (afterDrag) coordinator.markDragPreview(deck, 8.615104, false)
39+
const frameRevision = revision.value
40+
controller.queueFrame({
41+
renderToken: 1,
42+
renderRevision: frameRevision,
43+
playbackActive: false,
44+
rangeStartSec: -33.384896,
45+
rangeDurationSec: 84,
46+
viewportRangeStartSec: 2.615104,
47+
anchorSec: 8.615104,
48+
anchorStartedAtMs: performance.now(),
49+
playbackRate: 1,
50+
renderWidth: 8400,
51+
overscanCssPx: 3600,
52+
pixelRatio: 1.75
53+
})
54+
controller.handleRendered({
55+
renderToken: 1,
56+
rangeStartSec: -33.384896,
57+
rangeDurationSec: 84,
58+
ready: true
59+
})
60+
for (let i = 0; i < 8; i += 1) {
61+
playing = true
62+
controller.startPlayback(8.615104, 1)
63+
expect(controller.apply(8.815104, { requirePresentable: true }).applied).toBe(true)
64+
coordinator.markSeek(deck, 8.615104)
65+
playing = false
66+
controller.stopPlayback()
67+
expect(coordinator.state[deck].owner).toBe('seek')
68+
expect(revision.value).toBe(frameRevision)
69+
expect(controller.apply(8.615104, { requirePresentable: true }).applied).toBe(true)
70+
}
71+
expect(scheduleDraw).not.toHaveBeenCalled()
72+
// 超出缓存覆盖范围仍须请求新画面,不能显示旧位置。
73+
coordinator.markSeek(deck, 120)
74+
expect(controller.apply(120, { requirePresentable: true }).applied).toBe(false)
75+
}
76+
// 改变展示内容的拖动版本仍会失效旧帧。
77+
coordinator.markDragPreview(deck, 60, false)
78+
expect(controller.measure(8.615104).presentable).toBe(false)
79+
}
80+
)
81+
})

‎src/renderer/src/composables/horizontalBrowse/createHorizontalBrowseStableRenderRevision.ts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,14 +10,17 @@ export const createHorizontalBrowseStableRenderRevision = (
1010
const presentationRevision = Math.max(0, Math.floor(Number(state?.revision) || 0))
1111
if (
1212
state?.owner === 'linked-playback' ||
13-
state?.owner === 'seek' ||
1413
state?.owner === 'drag' ||
1514
state?.owner === 'linked-drag'
1615
) {
1716
lastPresentationStableRenderRevision = presentationRevision
1817
return presentationRevision
1918
}
19+
// Seek/Cue 只改变视口位置,不改变已经绘制的波形内容。保留画布版本,
20+
// 让覆盖目标位置的稳定帧直接平移复用;越出覆盖范围仍由 seek watcher 请求新帧。
21+
// 若把 seek 的展示事务版本作为内容版本,每次松开 Cue 都会清空块池并重画整片波形。
2022
if (
23+
state?.owner === 'seek' ||
2124
state?.owner === 'sync-transaction' ||
2225
(state?.owner === 'playback' &&
2326
state.sourceDeck === null &&

‎src/renderer/src/composables/horizontalBrowse/useHorizontalBrowseRenderSync.spec.ts‎

Lines changed: 56 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,7 @@ import {
66
import { useHorizontalBrowseRenderSync } from './useHorizontalBrowseRenderSync'
77

88
const createPlayingSnapshot = () => {
9-
const snapshot = createEmptyHorizontalBrowseTransportSnapshot()
10-
snapshot.stateRevision = 1
9+
const snapshot = { ...createEmptyHorizontalBrowseTransportSnapshot(), stateRevision: 1 }
1110
snapshot.top = {
1211
...snapshot.top,
1312
label: 'top',
@@ -60,6 +59,61 @@ const createRenderSync = (linkedGridVisualPending: () => boolean) => {
6059
}
6160

6261
describe('useHorizontalBrowseRenderSync', () => {
62+
it.each(['top', 'bottom'] as const)('另一轨反复 Cue 不应重置 %s Master 波形', (deck) => {
63+
const { snapshot, renderSync } = createRenderSync(() => false)
64+
const cueDeck = deck === 'top' ? 'bottom' : 'top'
65+
snapshot[cueDeck].playing = false
66+
snapshot[cueDeck].leader = false
67+
Object.assign(snapshot[deck], { leader: true, playbackRate: 1, renderCurrentSec: 10 })
68+
const playbackRevision =
69+
deck === 'top'
70+
? renderSync.topDeckPlaybackSyncRevision
71+
: renderSync.bottomDeckPlaybackSyncRevision
72+
const renderSeconds =
73+
deck === 'top'
74+
? renderSync.topDeckRenderCurrentSeconds
75+
: renderSync.bottomDeckRenderCurrentSeconds
76+
renderSync.syncDeckRenderState({ nowMs: 1000, snapshotAtMs: 1000 })
77+
const revision = playbackRevision.value
78+
for (let step = 1; step <= 6; step += 1) {
79+
snapshot.stateRevision += 1
80+
snapshot[cueDeck].playing = step % 2 === 1
81+
// Native output buffers and IPC timestamps can differ by a few milliseconds.
82+
snapshot[deck].renderCurrentSec = 10 + step * 0.2 - 0.008
83+
renderSync.syncDeckRenderState({ nowMs: 1000 + step * 200, snapshotAtMs: 1000 + step * 200 })
84+
expect(playbackRevision.value).toBe(revision)
85+
expect(renderSeconds.value).toBeCloseTo(10 + step * 0.2, 6)
86+
}
87+
})
88+
89+
it('跟随轨的真实相位校正仍会更新波形', () => {
90+
const { snapshot, renderSync } = createRenderSync(() => false)
91+
Object.assign(snapshot.bottom, { playbackRate: 1, renderCurrentSec: 10 })
92+
renderSync.syncDeckRenderState({ nowMs: 1000, snapshotAtMs: 1000 })
93+
const revision = renderSync.bottomDeckPlaybackSyncRevision.value
94+
snapshot.stateRevision += 1
95+
snapshot.bottom.renderCurrentSec = 10.208
96+
renderSync.syncDeckRenderState({ nowMs: 1200, snapshotAtMs: 1200 })
97+
expect(renderSync.bottomDeckPlaybackSyncRevision.value).toBe(revision + 1)
98+
expect(renderSync.bottomDeckRenderCurrentSeconds.value).toBeCloseTo(10.208, 6)
99+
})
100+
101+
it('Master 本轨的主动小幅跳转和明显偏差仍会更新波形', () => {
102+
const { snapshot, renderSync } = createRenderSync(() => false)
103+
snapshot.top.renderCurrentSec = 10
104+
renderSync.syncDeckRenderState({ nowMs: 1000, snapshotAtMs: 1000 })
105+
const revision = renderSync.topDeckPlaybackSyncRevision.value
106+
snapshot.stateRevision += 1
107+
snapshot.top.renderCurrentSec = 10.208
108+
renderSync.syncDeckRenderState({ nowMs: 1200, snapshotAtMs: 1200, force: 'top' })
109+
expect(renderSync.topDeckPlaybackSyncRevision.value).toBe(revision + 1)
110+
expect(renderSync.topDeckRenderCurrentSeconds.value).toBeCloseTo(10.208, 6)
111+
snapshot.top.renderCurrentSec = 20
112+
renderSync.syncDeckRenderState({ nowMs: 1400, snapshotAtMs: 1400 })
113+
expect(renderSync.topDeckPlaybackSyncRevision.value).toBe(revision + 2)
114+
expect(renderSync.topDeckRenderCurrentSeconds.value).toBe(20)
115+
})
116+
63117
it.each([0.5, 0.125, 0.0625, 0.03125, 0.0078125])(
64118
'两轨短 Loop(%s 秒)每帧回环,无需等待 native 快照',
65119
(loopDurationSec) => {

‎src/renderer/src/composables/horizontalBrowse/useHorizontalBrowseRenderSync.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -374,8 +374,10 @@ export const useHorizontalBrowseRenderSync = (params: UseHorizontalBrowseRenderS
374374
!!recentIntent &&
375375
recentIntentAgeMs <= RENDER_SYNC_RECENT_INTENT_CONFIRM_MS &&
376376
Math.abs(snapshotSec - recentIntent.seconds) <= RENDER_SYNC_PENDING_INTENT_EPSILON_SEC
377+
// Native 只校正跟随轨的相位;另一轨的 Cue 也会更新全局版本,不能因此重置 Master 时钟。
377378
const fullSyncPhaseChanged =
378379
stateRevisionChanged &&
380+
!snapshot.leader &&
379381
snapshot.syncEnabled &&
380382
snapshot.syncLock === 'full' &&
381383
playbackSnapshotAuthoritative &&

‎src/renderer/src/pages/homePage.vue‎

Lines changed: 26 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,14 @@ import librarySelectArea from './modules/librarySelectArea.vue'
33
import libraryArea from './modules/libraryArea.vue'
44
import pioneerDeviceLibraryArea from './modules/pioneerDeviceLibraryArea.vue'
55
import songsArea from './modules/songsArea/songsArea.vue'
6+
import SongsAreaPreviewHost from './homePage/SongsAreaPreviewHost'
7+
import { useSongsAreaPaneViews } from './homePage/useSongsAreaPaneViews'
68
import pioneerSongsArea from './modules/pioneerSongsArea.vue'
79
import {
810
type ISongsAreaPaneRuntimeState,
911
type LibrarySelection,
1012
type SplitSongsAreaPaneKey,
13+
type SongsAreaPaneKey,
1114
useRuntimeStore
1215
} from '@renderer/stores/runtime'
1316
import { onMounted, onUnmounted, ref, computed, watch } from 'vue'
@@ -164,7 +167,6 @@ onUnmounted(() => {
164167
165168
let librarySelected = ref('FilterLibrary')
166169
type CoreLibraryName = 'FilterLibrary' | 'CuratedLibrary' | 'SetLibrary' | 'MixtapeLibrary'
167-
const splitPaneKeys: SplitSongsAreaPaneKey[] = ['left', 'right']
168170
const normalizeLibraryPath = (value: string) => (value || '').replace(/\\/g, '/')
169171
const isCoreLibraryName = (value: string): value is CoreLibraryName =>
170172
['FilterLibrary', 'CuratedLibrary', 'SetLibrary', 'MixtapeLibrary'].includes(value)
@@ -385,6 +387,7 @@ const showMainSongPlayer = computed(
385387
const isSongsAreaSplit = computed(
386388
() => runtime.songsAreaPanels.splitEnabled && !isPioneerDeviceLibraryView.value
387389
)
390+
const visiblePaneViews = useSongsAreaPaneViews(runtime.songsAreaPanels.panes, isSongsAreaSplit)
388391
const showEmptyRecycleBinButton = computed(
389392
() =>
390393
isRecycleBinView.value &&
@@ -438,9 +441,8 @@ watch(
438441
singlePaneHeaderRevealPending.value = false
439442
}
440443
)
441-
const isSplitPaneActive = (pane: SplitSongsAreaPaneKey) =>
442-
runtime.songsAreaPanels.activePane === pane
443-
const handleSplitPaneMouseDown = (pane: SplitSongsAreaPaneKey) => {
444+
const isSplitPaneActive = (pane: SongsAreaPaneKey) => runtime.songsAreaPanels.activePane === pane
445+
const handleSplitPaneMouseDown = (pane: SongsAreaPaneKey) => {
444446
// 激活歌曲操作的目标面板,不联动左侧正在浏览的库。
445447
activateSongsAreaPane(runtime, pane)
446448
}
@@ -760,34 +762,41 @@ const drop = async (e: DragEvent) => {
760762
style="width: 100%; height: 100%; min-width: 0"
761763
/>
762764
<div
763-
v-else-if="isSongsAreaSplit"
765+
v-else
764766
ref="splitSongsAreaRef"
765-
class="splitSongsArea"
766-
:style="splitSongsAreaStyle"
767+
:class="isSongsAreaSplit ? 'splitSongsArea' : 'singleSongsAreaShell'"
768+
:style="isSongsAreaSplit ? splitSongsAreaStyle : undefined"
767769
>
770+
<SongsAreaPreviewHost />
768771
<div
769-
v-for="pane in splitPaneKeys"
770-
:key="pane"
771-
class="splitSongsAreaPane"
772-
:class="[`splitSongsAreaPane--${pane}`, { 'is-active': isSplitPaneActive(pane) }]"
772+
v-for="{ pane, viewId } in visiblePaneViews"
773+
:key="viewId"
774+
:class="
775+
isSongsAreaSplit
776+
? [
777+
'splitSongsAreaPane',
778+
`splitSongsAreaPane--${pane}`,
779+
{ 'is-active': isSplitPaneActive(pane) }
780+
]
781+
: 'singleSongsAreaShell'
782+
"
783+
:style="!isSongsAreaSplit ? { flex: 1, minHeight: 0 } : undefined"
773784
@mousedown.capture="handleSplitPaneMouseDown(pane)"
774785
>
775786
<SongsAreaPaneHeader
787+
v-if="isSongsAreaSplit || showSingleSongsAreaHeader"
776788
:pane="pane"
777789
v-model:search-query="runtime.songsAreaPanels.panes[pane].searchQuery"
778790
v-model:compatible-key-only="runtime.songsAreaPanels.panes[pane].compatibleKeyOnly"
779791
:title="resolveSongsPaneTitle(pane)"
780792
:show-empty-recycle-bin-button="showEmptyRecycleBinButton"
781793
@empty-recycle-bin="emptyRecycleBinWithOptimisticUpdate(runtime)"
782-
@close="handleSplitPaneClose(pane)"
783-
/>
784-
<songsArea
785-
:pane="pane"
786-
:enable-preview-player="pane === 'left'"
787-
style="flex: 1; min-width: 0; min-height: 0"
794+
@close="pane === 'single' ? handleSinglePaneClose() : handleSplitPaneClose(pane)"
788795
/>
796+
<songsArea :pane="pane" style="flex: 1; min-width: 0; min-height: 0" />
789797
</div>
790798
<div
799+
v-if="isSongsAreaSplit"
791800
class="splitSongsAreaDivider"
792801
:class="{
793802
'is-hovered': splitDividerHovered,
@@ -800,23 +809,6 @@ const drop = async (e: DragEvent) => {
800809
@mouseleave="splitDividerHovered = isSongsAreaSplitDividerResizing"
801810
></div>
802811
</div>
803-
<div v-else class="singleSongsAreaShell">
804-
<SongsAreaPaneHeader
805-
v-if="showSingleSongsAreaHeader"
806-
pane="single"
807-
v-model:search-query="runtime.songsAreaPanels.panes.single.searchQuery"
808-
v-model:compatible-key-only="runtime.songsAreaPanels.panes.single.compatibleKeyOnly"
809-
:title="resolveSongsPaneTitle('single')"
810-
:show-empty-recycle-bin-button="showEmptyRecycleBinButton"
811-
@empty-recycle-bin="emptyRecycleBinWithOptimisticUpdate(runtime)"
812-
@close="handleSinglePaneClose"
813-
/>
814-
<songsArea
815-
pane="single"
816-
:enable-preview-player="true"
817-
style="flex: 1; min-width: 0; min-height: 0"
818-
/>
819-
</div>
820812
</div>
821813
</div>
822814
<div class="mainPlayerShell" :class="{ 'mainPlayerShell--visible': showMainSongPlayer }">
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import { defineComponent } from 'vue'
2+
import { useWaveformPreviewPlayer } from '../modules/songsArea/composables/useWaveformPreviewPlayer'
3+
4+
// One preview player for the local list layout, independent of the number or order of panes.
5+
export default defineComponent({
6+
setup() {
7+
useWaveformPreviewPlayer()
8+
return () => null
9+
}
10+
})

0 commit comments

Comments
 (0)