diff --git a/CHANGELOG.md b/CHANGELOG.md index de75b99cf..64e4c894a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,8 @@ ### Fixed +- Kept the rehearsal player section picker aligned with the selected player or + vocal role while preserving the full song-form roadmap. - Upgraded the local score PDF parser to `pdfjs-dist` 6.2.108, pinned Undici 7.29.0 across the workspace, and constrained PDF loading to copied in-memory bytes with a same-origin bundled worker and npm-generated lock provenance. ## [0.1.3] - 2026-04-29 @@ -75,4 +77,4 @@ - `ChordsFeature` (코드 분석) 화면에서 각 파트(Role)의 `transpositionPlan`(이조/조옮김 계획)을 표시하는 기능을 추가했습니다. - `RangesFeature` (음역대 분석) 화면에서 겹침 경고(Overlap warning) 외에 해당 파트의 채보(Transcription) 가능 노드 수를 요약하여 보여주는 기능을 추가했습니다. -- 신규 UI 요소에 대한 단위 테스트를 추가했습니다 (`apps/desktop/src/features/chords/index.test.tsx`, `apps/desktop/src/features/ranges/index.test.tsx`). \ No newline at end of file +- 신규 UI 요소에 대한 단위 테스트를 추가했습니다 (`apps/desktop/src/features/chords/index.test.tsx`, `apps/desktop/src/features/ranges/index.test.tsx`). diff --git a/apps/desktop/src/features/workspace/RehearsalPlayer.test.tsx b/apps/desktop/src/features/workspace/RehearsalPlayer.test.tsx index 80ac13db2..453cc3ecf 100644 --- a/apps/desktop/src/features/workspace/RehearsalPlayer.test.tsx +++ b/apps/desktop/src/features/workspace/RehearsalPlayer.test.tsx @@ -128,6 +128,91 @@ describe("RehearsalPlayer", () => { ).not.toMatch(/Count in 4 beats/i); }); + it("limits the section picker to sections containing the active role", () => { + setNavigatorLanguage("en-US"); + const song = createDemoRehearsalSong(); + const chorus = structuredClone(song.sections[0]!); + chorus.id = "chorus-1"; + chorus.label = "chorus"; + chorus.timeRange = { start: 40, end: 64 }; + chorus.roles = chorus.roles.filter((role) => role.id !== "lead-vocal"); + song.sections.push(chorus); + + render( + , + ); + + expect( + screen.getByRole("group", { name: "Playable sections for Lead Vocal" }), + ).toBeTruthy(); + expect(screen.getByRole("button", { name: /verse/i })).toBeTruthy(); + expect(screen.queryByRole("button", { name: /chorus/i })).toBeNull(); + expect(screen.getByTestId("rehearsal-loop-role-filter")).toHaveTextContent( + "Showing sections that include Lead Vocal.", + ); + }); + + it("keeps the selected loop by section ID when an earlier section is filtered out", () => { + setNavigatorLanguage("en-US"); + const song = createDemoRehearsalSong(); + const verse = structuredClone(song.sections[0]!); + verse.id = "verse-no-lead-vocal"; + verse.roles = verse.roles.filter((role) => role.id !== "lead-vocal"); + const chorus = structuredClone(song.sections[0]!); + chorus.id = "chorus-1"; + chorus.label = "chorus"; + chorus.timeRange = { start: 40, end: 64 }; + song.sections = [verse, chorus]; + + const { rerender } = render(); + fireEvent.click(screen.getByRole("button", { name: /chorus/i })); + expect( + screen + .getByRole("button", { name: /chorus/i }) + .getAttribute("aria-pressed"), + ).toBe("true"); + + rerender( + , + ); + + expect( + screen + .getByRole("button", { name: /chorus/i }) + .getAttribute("aria-pressed"), + ).toBe("true"); + expect(screen.getByTestId("rehearsal-loop-next-action")).toHaveTextContent( + /Map chorus from 0:40–1:04/i, + ); + }); + + it("explains when the active role has no playable sections", () => { + setNavigatorLanguage("en-US"); + const song = createDemoRehearsalSong(); + song.sections[0]!.roles = []; + + render( + , + ); + + expect(screen.getByTestId("rehearsal-loop-next-action")).toHaveTextContent( + "No playable sections include Lead Vocal yet.", + ); + expect(screen.queryByRole("group")).toBeNull(); + }); + it("stops active count-in and loop ticking when local-audio authority is revoked", () => { setNavigatorLanguage("en-US"); vi.useFakeTimers(); diff --git a/apps/desktop/src/features/workspace/RehearsalPlayer.tsx b/apps/desktop/src/features/workspace/RehearsalPlayer.tsx index cb438d19c..d344b002b 100644 --- a/apps/desktop/src/features/workspace/RehearsalPlayer.tsx +++ b/apps/desktop/src/features/workspace/RehearsalPlayer.tsx @@ -33,6 +33,8 @@ interface RehearsalPlayerProps { song: RehearsalSong; hasLocalAudio?: boolean; audioSourcePath?: string | null; + activeRole?: string | null; + activeRoleName?: string | null; startNonce?: number; } @@ -89,19 +91,30 @@ function hasSameLoopTiming( ); } +/** Return a stable selection key when analysis emits duplicate section IDs. */ +function loopSelectionKey(loop: RehearsalLoopWindow): string { + return `${loop.sectionId}:${loop.startSeconds}:${loop.endSeconds}`; +} + /** Render tonight's first section loop with a count-in and a named next action. */ export function RehearsalPlayer({ song, hasLocalAudio = false, audioSourcePath = null, + activeRole = null, + activeRoleName = null, startNonce = 0, }: RehearsalPlayerProps): ReactElement { const t = useMemo(() => createTranslator(detectPreferredLocale()), []); - const playableLoops = useMemo(() => resolveLoopWindows(song), [song]); - const [selectedSectionIndex, setSelectedSectionIndex] = useState(0); - const selectedRendererIndex = playableLoops[selectedSectionIndex] - ? selectedSectionIndex - : 0; + const playableLoops = useMemo( + () => resolveLoopWindows(song, activeRole), + [activeRole, song], + ); + const [selectedLoopKey, setSelectedLoopKey] = useState(null); + const selectedLoop = + playableLoops.find((loop) => loopSelectionKey(loop) === selectedLoopKey) ?? + playableLoops[0] ?? + null; const [transport, setTransport] = useState(() => reduceRehearsalTransport(createIdleTransportState(), { type: "arm", @@ -184,24 +197,26 @@ export function RehearsalPlayer({ }, [audioSourceUrl, hasNativeAudioConversionError]); useEffect(() => { - const nextLoop = playableLoops[selectedRendererIndex] ?? null; setTransport((current) => { if ( current.loop && - nextLoop && - hasSameLoopTiming(current.loop, nextLoop) + selectedLoop && + hasSameLoopTiming(current.loop, selectedLoop) ) { if ( - current.loop.sectionLabel === nextLoop.sectionLabel && - current.loop.tempoAssumed === nextLoop.tempoAssumed + current.loop.sectionLabel === selectedLoop.sectionLabel && + current.loop.tempoAssumed === selectedLoop.tempoAssumed ) { return current; } - return { ...current, loop: nextLoop }; + return { ...current, loop: selectedLoop }; } - return reduceRehearsalTransport(current, { type: "arm", loop: nextLoop }); + return reduceRehearsalTransport(current, { + type: "arm", + loop: selectedLoop, + }); }); - }, [playableLoops, selectedRendererIndex]); + }, [selectedLoop]); useEffect(() => { const audio = audioRef.current; @@ -226,7 +241,6 @@ export function RehearsalPlayer({ if (!hasPlayableAudio) { return; } - const selectedLoop = playableLoops[selectedRendererIndex] ?? null; if (selectedLoop) { setPlaybackError(false); startAudio(selectedLoop, false); @@ -242,8 +256,7 @@ export function RehearsalPlayer({ startAudio, startNonce, hasPlayableAudio, - playableLoops, - selectedRendererIndex, + selectedLoop, ]); useEffect(() => { @@ -397,10 +410,20 @@ export function RehearsalPlayer({ ]); const actionKey = nextActionTemplateKey(transport, hasPlayableAudio); - const nextAction = fillRehearsalCopy( - t(actionKey as TranslationKey), - nextActionValues(transport), - ); + const nextAction = + activeRoleName && playableLoops.length === 0 + ? fillRehearsalCopy(t("workspaceLoopNoRoleSections"), { + roleName: activeRoleName, + }) + : fillRehearsalCopy( + t(actionKey as TranslationKey), + nextActionValues(transport), + ); + const sectionPickerLabel = activeRoleName + ? fillRehearsalCopy(t("workspaceLoopSectionPickerForRole"), { + roleName: activeRoleName, + }) + : t("workspaceLoopSectionPickerLabel"); const canStart = transport.loop !== null && hasPlayableAudio && @@ -429,17 +452,30 @@ export function RehearsalPlayer({ > {nextAction}

+ {activeRoleName && playableLoops.length > 0 ? ( +

+ {fillRehearsalCopy(t("workspaceLoopRoleFilterHint"), { + roleName: activeRoleName, + })} +

+ ) : null} {playableLoops.length > 0 ? (
{playableLoops.map((loop, index) => { - const selected = index === selectedRendererIndex; + const selectionKey = loopSelectionKey(loop); + const selected = + selectedLoop !== null && + selectionKey === loopSelectionKey(selectedLoop); return (
- {activeRole && ( + {resolvedActiveRole && (

Stem Player

-

{activeRoleDetails?.name ?? activeRole}

+

{activeRoleDetails?.name ?? resolvedActiveRole}