diff --git a/docs/code-map.md b/docs/code-map.md
index 3ceceae2..a790ea26 100644
--- a/docs/code-map.md
+++ b/docs/code-map.md
@@ -86,7 +86,9 @@ Where things live in `src/` and what each part calls. Setup and scripts are in t
recorded in fluent-mobile, guarded to Project Managers — the route redirects anyone else and
the drafting header only links it for PMs. `components/AudioReviewPage.tsx` lists a chapter's
takes by verse or pericope (`components/TakeCard.tsx`, `components/AudioPlayer.tsx`), flags
- conflicting ones and lets the PM pick the winner. It calls `GET /verse-audio`
+ conflicting ones and lets the PM pick the winner. For pericopes recorded only by verse,
+ `components/StitchedAudioPlayer.tsx` and `hooks/useStitchedAudio.ts` sequence active verse takes
+ into continuous playback. It calls `GET /verse-audio`
(`hooks/useChapterAudio.ts`, which `DraftingUI` also uses for the conflict indicator and review
link) and `POST /verse-audio/:projectUnitId/:bibleTextId/resolve`
(`hooks/useResolveConflict.ts`).
diff --git a/docs/features/audio-playback/design.md b/docs/features/audio-playback/design.md
index 71e1a03c..e01e9439 100644
--- a/docs/features/audio-playback/design.md
+++ b/docs/features/audio-playback/design.md
@@ -185,7 +185,7 @@ The drafting delivery includes source and reference Bible audio in verse, perico
Translation Notes, Questions, Words, and Open Study Notes remain later delivery. Their concrete controls and article chunking will be decided with that work. The native FIA guide/key-term player now honors the aggregate audio gate and Hide Audio, but it remains outside the shared playback registry. Later resource/FIA work can add app-wide exclusivity and decide whether FIA receives redesigned controls. Audio-only source Bibles are not ruled out by the segment model.
-Audio review (take evaluation and conflict resolution) connects to the shared `PlaybackRegistry` for app-wide playback exclusivity: claiming playback immediately pauses any active drafting or resource audio, and subsequent sounding claimants pause the review player. Because take review requires side-by-side comparison of multiple candidate takes for the same verse or pericope with per-take visual waveform progress and fast A/B scrubbing, it retains a lightweight take-comparison player rather than the drafting queue, ScrubBar, and segment-resolution stack.
+Audio review (take evaluation and conflict resolution) connects to the shared `PlaybackRegistry` for app-wide playback exclusivity: claiming playback immediately pauses any active drafting or resource audio, and subsequent sounding claimants pause the review player. Because take review requires side-by-side comparison of multiple candidate takes for the same verse or pericope with per-take visual waveform progress and fast A/B scrubbing, it retains a lightweight take-comparison player rather than the drafting queue, ScrubBar, and segment-resolution stack. Similarly, continuous playback of pericopes recorded only by verse uses a dedicated `StitchedAudioPlayer` that sequences active verse recordings within audio review and registers with `PlaybackRegistry`, rather than invoking the full drafting playback engine.
## 16. Current delivery decisions and later follow-ups
diff --git a/src/features/audio/components/AudioReviewPage.test.tsx b/src/features/audio/components/AudioReviewPage.test.tsx
new file mode 100644
index 00000000..8ffe38fa
--- /dev/null
+++ b/src/features/audio/components/AudioReviewPage.test.tsx
@@ -0,0 +1,790 @@
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import { act, fireEvent, render, screen } from '@testing-library/react';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { PlaybackRegistryProvider } from '@/features/tts';
+import { type PericopeGroup, type ProjectItem, type VerseAudioRecording } from '@/lib/types';
+import { useAppStore } from '@/store/store';
+
+import AudioReviewPage from './AudioReviewPage';
+
+vi.mock('react-i18next', () => import('@/test/i18n'));
+
+const mockRouter = {
+ history: {
+ back: vi.fn(),
+ },
+};
+
+const mockProjectItem = {
+ chapterAssignmentId: 101,
+ projectId: 1,
+ projectName: 'Test Project',
+ bookId: 1,
+ bookCode: 'GEN',
+ book: 'Genesis',
+ chapterNumber: 6,
+ projectUnitId: 1,
+} as unknown as ProjectItem;
+
+const mockRecordings = [
+ {
+ id: 1,
+ projectUnitId: 1,
+ bibleTextId: 1,
+ uploadedBy: 1,
+ contentType: 'audio/webm',
+ sizeBytes: 1000,
+ durationSeconds: 5,
+ versionToken: 1,
+ downloadUrl: 'https://example.com/1.webm',
+ verseNumber: 1,
+ conflictStatus: 'clean',
+ activeTakeId: 11,
+ createdAt: '2026-03-01T00:00:00Z',
+ updatedAt: '2026-03-01T00:00:00Z',
+ takes: [
+ {
+ id: 11,
+ uploadedBy: 1,
+ contentType: 'audio/webm',
+ sizeBytes: 1000,
+ durationSeconds: 5,
+ contentHash: 'hash-11',
+ downloadUrl: 'https://example.com/11.webm',
+ createdAt: '2026-03-01T00:00:00Z',
+ updatedAt: '2026-03-01T00:00:00Z',
+ },
+ ],
+ },
+ {
+ id: 2,
+ projectUnitId: 1,
+ bibleTextId: 2,
+ uploadedBy: 1,
+ contentType: 'audio/webm',
+ sizeBytes: 1000,
+ durationSeconds: 5,
+ versionToken: 1,
+ downloadUrl: 'https://example.com/2.webm',
+ verseNumber: 2,
+ conflictStatus: 'clean',
+ activeTakeId: 12,
+ createdAt: '2026-03-01T00:00:00Z',
+ updatedAt: '2026-03-01T00:00:00Z',
+ takes: [
+ {
+ id: 12,
+ uploadedBy: 1,
+ contentType: 'audio/webm',
+ sizeBytes: 1000,
+ durationSeconds: 5,
+ contentHash: 'hash-12',
+ downloadUrl: 'https://example.com/12.webm',
+ createdAt: '2026-03-01T00:00:00Z',
+ updatedAt: '2026-03-01T00:00:00Z',
+ },
+ ],
+ },
+ {
+ id: 3,
+ projectUnitId: 1,
+ bibleTextId: 3,
+ uploadedBy: 1,
+ contentType: 'audio/webm',
+ sizeBytes: 1000,
+ durationSeconds: 5,
+ versionToken: 1,
+ downloadUrl: 'https://example.com/3.webm',
+ verseNumber: 3,
+ conflictStatus: 'clean',
+ activeTakeId: 13,
+ createdAt: '2026-03-01T00:00:00Z',
+ updatedAt: '2026-03-01T00:00:00Z',
+ takes: [
+ {
+ id: 13,
+ uploadedBy: 1,
+ contentType: 'audio/webm',
+ sizeBytes: 1000,
+ durationSeconds: 5,
+ contentHash: 'hash-13',
+ downloadUrl: 'https://example.com/13.webm',
+ createdAt: '2026-03-01T00:00:00Z',
+ updatedAt: '2026-03-01T00:00:00Z',
+ },
+ ],
+ },
+] as unknown as VerseAudioRecording[];
+
+const mockPericopes: PericopeGroup[] = [
+ {
+ pericopeNumber: '1',
+ pericopeTitle: null,
+ verses: [
+ { verseNumber: 1, chapterNumber: 6 },
+ { verseNumber: 2, chapterNumber: 6 },
+ ],
+ },
+ {
+ pericopeNumber: '2',
+ pericopeTitle: null,
+ verses: [{ verseNumber: 3, chapterNumber: 6 }],
+ },
+];
+
+vi.mock('@tanstack/react-router', () => ({
+ getRouteApi: () => ({
+ useLoaderData: () => ({ projectItem: mockProjectItem }),
+ }),
+ useRouter: () => mockRouter,
+}));
+
+let currentRecordings = [...mockRecordings];
+let currentPericopes: PericopeGroup[] = mockPericopes;
+
+vi.mock('@/features/audio/hooks/useChapterAudio', () => ({
+ useChapterAudio: () => ({
+ data: { items: currentRecordings, total: currentRecordings.length },
+ isLoading: false,
+ }),
+}));
+
+vi.mock('@/features/projects/hooks/useProjectUsers', () => ({
+ useProjectUsers: () => ({
+ data: [{ userId: 1, displayName: 'Alice' }],
+ }),
+}));
+
+vi.mock('@/features/pericopes/hooks/useChapterPericopes', () => ({
+ useChapterPericopes: () => ({
+ data: currentPericopes,
+ }),
+}));
+
+vi.mock('@/features/audio/hooks/useResolveConflict', () => ({
+ useResolveConflict: () => ({
+ mutate: vi.fn(),
+ }),
+}));
+
+vi.mock('@/features/bible/hooks/useBibleTarget', () => ({
+ targetTextQueryOptions: () => ({
+ queryKey: ['target-text'],
+ queryFn: () => [],
+ }),
+}));
+
+function renderPage() {
+ const queryClient = new QueryClient({
+ defaultOptions: { queries: { retry: false } },
+ });
+ return render(
+
+
+
+
+
+ );
+}
+
+describe('AudioReviewPage display mode', () => {
+ beforeEach(() => {
+ currentRecordings = [...mockRecordings];
+ vi.spyOn(HTMLMediaElement.prototype, 'play').mockImplementation(() => Promise.resolve());
+ vi.spyOn(HTMLMediaElement.prototype, 'pause').mockImplementation(() => {});
+ useAppStore.setState({
+ displayMode: 'verse',
+ userdetail: {
+ id: 1,
+ username: 'alice',
+ displayName: 'Alice',
+ email: 'alice@example.com',
+ role: 'user',
+ grants: [
+ {
+ orgId: 1,
+ projectId: 1,
+ roleId: 1,
+ roleName: 'project_manager',
+ permissions: [],
+ },
+ ],
+ lastActiveOrgId: 1,
+ },
+ });
+ });
+
+ it('renders verses when global displayMode is verse', () => {
+ renderPage();
+
+ expect(screen.getByText('Verse 1')).toBeInTheDocument();
+ expect(screen.getByText('Verse 2')).toBeInTheDocument();
+ expect(screen.getByText('Verse 3')).toBeInTheDocument();
+ });
+
+ it('reacts immediately when displayMode is changed in the global settings to pericope', () => {
+ renderPage();
+
+ expect(screen.getByText('Verse 1')).toBeInTheDocument();
+
+ act(() => {
+ useAppStore.getState().setDisplayMode('pericope');
+ });
+
+ expect(screen.getByText('Verses 1–2')).toBeInTheDocument();
+ expect(screen.getByText('Verse 3')).toBeInTheDocument();
+ });
+
+ it('switches to verse view locally when clicking page-specified switch button in conflict banner, keeping global setting unchanged', () => {
+ currentRecordings = [
+ {
+ ...mockRecordings[0],
+ conflictStatus: 'conflict',
+ },
+ mockRecordings[1],
+ mockRecordings[2],
+ ];
+ useAppStore.setState({ displayMode: 'pericope' });
+ const { unmount } = renderPage();
+
+ expect(screen.getByText('Verses 1–2')).toBeInTheDocument();
+
+ // Click page-specified "Switch to Verse view" in the conflict banner
+ const switchButton = screen.getByRole('button', { name: /switch to verse view/i });
+ fireEvent.click(switchButton);
+
+ // Page view switches to verse locally
+ expect(screen.getByText('Verse 1')).toBeInTheDocument();
+ expect(screen.getByText('Verse 2')).toBeInTheDocument();
+
+ // Global store displayMode remains 'pericope' untouched
+ expect(useAppStore.getState().displayMode).toBe('pericope');
+
+ // When the user moves out and back in (remounts the page), it still respects the global setting ('pericope')
+ unmount();
+ renderPage();
+ expect(screen.getByText('Verses 1–2')).toBeInTheDocument();
+ });
+
+ it('selects the conflicted verse when switching to verse view from a pericope with conflict on a later verse', () => {
+ currentRecordings = [
+ mockRecordings[0],
+ {
+ ...mockRecordings[1], // verse 2 has the conflict
+ conflictStatus: 'conflict',
+ },
+ mockRecordings[2],
+ ];
+ useAppStore.setState({ displayMode: 'pericope' });
+ renderPage();
+
+ const switchButton = screen.getByRole('button', { name: /switch to verse view/i });
+ fireEvent.click(switchButton);
+
+ // Selected passage in verse view should be Verse 2, showing its conflicting takes header
+ expect(
+ screen.getByRole('heading', { level: 2, name: /conflicting takes/i })
+ ).toBeInTheDocument();
+ // Verse 2 list item should be selected (has bg-accent class)
+ const verse2Button = screen.getByRole('button', { name: /verse 2/i });
+ expect(verse2Button).toHaveClass('bg-accent');
+ });
+});
+
+describe('AudioReviewPage pericope stitched audio & cross-view conflicts (Issue #554)', () => {
+ beforeEach(() => {
+ currentRecordings = [...mockRecordings];
+ currentPericopes = mockPericopes;
+ vi.spyOn(HTMLMediaElement.prototype, 'play').mockImplementation(() => Promise.resolve());
+ vi.spyOn(HTMLMediaElement.prototype, 'pause').mockImplementation(() => {});
+ useAppStore.setState({
+ displayMode: 'pericope',
+ userdetail: {
+ id: 1,
+ username: 'alice',
+ displayName: 'Alice',
+ email: 'alice@example.com',
+ role: 'user',
+ grants: [
+ {
+ orgId: 1,
+ projectId: 1,
+ roleId: 1,
+ roleName: 'Project Manager',
+ permissions: [],
+ },
+ ],
+ lastActiveOrgId: 1,
+ },
+ });
+ });
+
+ it('renders stitched audio player and correct subtitle for verse-recorded pericopes without conflicts', () => {
+ renderPage();
+
+ // Subtitle under Recorded Takes
+ expect(screen.getByText('Recorded by verse. Played as one recording.')).toBeInTheDocument();
+
+ // Stitched card with heading
+ expect(screen.getByTestId('stitched-audio-card')).toBeInTheDocument();
+ expect(screen.getByText('Stitched from verse recordings, vv. 1-2')).toBeInTheDocument();
+
+ // No individual TakeCard elements rendered
+ expect(screen.queryByText(/Take 1/)).not.toBeInTheDocument();
+ });
+
+ it('shows skipped verse notes when a verse has conflict in pericope view', () => {
+ currentRecordings = [
+ mockRecordings[0], // verse 1 clean
+ {
+ ...mockRecordings[1],
+ conflictStatus: 'conflict', // verse 2 conflict
+ },
+ mockRecordings[2],
+ ];
+
+ renderPage();
+
+ expect(screen.getByTestId('stitched-audio-card')).toBeInTheDocument();
+ expect(screen.getByText('Verse 2 not included: conflict')).toBeInTheDocument();
+ });
+
+ it('shows skipped verse notes when a verse is unrecorded in pericope view', () => {
+ // Only verse 1 is recorded in pericope 1-2, verse 2 is missing
+ currentRecordings = [
+ mockRecordings[0],
+ mockRecordings[2], // verse 3
+ ];
+
+ renderPage();
+
+ expect(screen.getByTestId('stitched-audio-card')).toBeInTheDocument();
+ expect(screen.getByText('Verse 2 not included: no recording')).toBeInTheDocument();
+ });
+
+ it('displays cross-view warning indicator and banner in pericope view when verse has conflict', () => {
+ currentRecordings = [
+ {
+ ...mockRecordings[0],
+ conflictStatus: 'conflict',
+ },
+ mockRecordings[1],
+ mockRecordings[2],
+ ];
+
+ renderPage();
+
+ // Left column warning indicator
+ expect(screen.getByText('Conflict in verse 1')).toBeInTheDocument();
+
+ // Center column conflict banner
+ expect(screen.getByText(/This conflict was recorded by verse\./)).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: /switch to verse view/i })).toBeInTheDocument();
+
+ // In opposing mode, no "Select as Draft" button is rendered
+ expect(screen.queryByText(/Select as Draft/i)).not.toBeInTheDocument();
+ });
+
+ it('displays cross-view warning indicator and banner in verse view when pericope has conflict', () => {
+ useAppStore.setState({ displayMode: 'verse' });
+
+ // Pericope covering verses 1-2 is in conflict
+ currentRecordings = [
+ {
+ id: 99,
+ projectUnitId: 1,
+ bibleTextId: 1,
+ uploadedBy: 1,
+ contentType: 'audio/webm',
+ sizeBytes: 1000,
+ durationSeconds: 10,
+ versionToken: 1,
+ downloadUrl: 'https://example.com/p.webm',
+ verseNumber: 1,
+ isPericope: true,
+ pericopeRange: [1, 2],
+ conflictStatus: 'conflict',
+ activeTakeId: 991,
+ createdAt: '2026-03-01T00:00:00Z',
+ updatedAt: '2026-03-01T00:00:00Z',
+ takes: [
+ {
+ id: 991,
+ uploadedBy: 1,
+ contentType: 'audio/webm',
+ sizeBytes: 1000,
+ durationSeconds: 10,
+ contentHash: 'hash-991',
+ downloadUrl: 'https://example.com/991.webm',
+ createdAt: '2026-03-01T00:00:00Z',
+ updatedAt: '2026-03-01T00:00:00Z',
+ },
+ ],
+ } as unknown as VerseAudioRecording,
+ mockRecordings[2], // verse 3
+ ];
+
+ renderPage();
+
+ // Left column warning indicator on verse 1 and verse 2
+ expect(screen.getAllByText('Conflict in pericope vv. 1-2')).toHaveLength(2);
+
+ // Center column banner
+ expect(screen.getByText(/This conflict was recorded as a pericope\./)).toBeInTheDocument();
+ const switchBtn = screen.getByRole('button', { name: /switch to pericope view/i });
+ expect(switchBtn).toBeInTheDocument();
+
+ // Conflicting pericope takes are suppressed in verse view
+ expect(screen.queryByText(/Take.*Pericope/)).not.toBeInTheDocument();
+ expect(screen.queryByText(/Select as Draft/i)).not.toBeInTheDocument();
+
+ // Clicking switch to pericope switches local view
+ fireEvent.click(switchBtn);
+ expect(screen.getByText('Verses 1–2')).toBeInTheDocument();
+ });
+
+ it('renders pericope takes under covered verses in verse view when pericope is clean', () => {
+ useAppStore.setState({ displayMode: 'verse' });
+
+ currentRecordings = [
+ mockRecordings[0], // verse 1 has its own verse recording
+ {
+ id: 99,
+ projectUnitId: 1,
+ bibleTextId: 1,
+ uploadedBy: 1,
+ contentType: 'audio/webm',
+ sizeBytes: 1000,
+ durationSeconds: 10,
+ versionToken: 1,
+ downloadUrl: 'https://example.com/p.webm',
+ verseNumber: 1,
+ isPericope: true,
+ pericopeRange: [1, 2],
+ conflictStatus: 'clean',
+ activeTakeId: 991,
+ createdAt: '2026-03-01T00:00:00Z',
+ updatedAt: '2026-03-01T00:00:00Z',
+ takes: [
+ {
+ id: 991,
+ uploadedBy: 1,
+ contentType: 'audio/webm',
+ sizeBytes: 1000,
+ durationSeconds: 10,
+ contentHash: 'hash-991',
+ downloadUrl: 'https://example.com/991.webm',
+ createdAt: '2026-03-01T00:00:00Z',
+ updatedAt: '2026-03-01T00:00:00Z',
+ },
+ ],
+ } as unknown as VerseAudioRecording,
+ ];
+
+ renderPage();
+
+ // Should display verse 1 take and pericope take labeled "Take 1 - Pericope - vv. 1-2"
+ expect(screen.getByText('Take 1 - Pericope - vv. 1-2')).toBeInTheDocument();
+ });
+
+ it('renders pericope takes under covered verses in chapter view when pericope is clean', () => {
+ useAppStore.setState({ displayMode: 'chapter' });
+
+ currentRecordings = [
+ mockRecordings[0],
+ {
+ id: 99,
+ projectUnitId: 1,
+ bibleTextId: 1,
+ uploadedBy: 1,
+ contentType: 'audio/webm',
+ sizeBytes: 1000,
+ durationSeconds: 10,
+ versionToken: 1,
+ downloadUrl: 'https://example.com/p.webm',
+ verseNumber: 1,
+ isPericope: true,
+ pericopeRange: [1, 2],
+ conflictStatus: 'clean',
+ activeTakeId: 991,
+ createdAt: '2026-03-01T00:00:00Z',
+ updatedAt: '2026-03-01T00:00:00Z',
+ takes: [
+ {
+ id: 991,
+ uploadedBy: 1,
+ contentType: 'audio/webm',
+ sizeBytes: 1000,
+ durationSeconds: 10,
+ contentHash: 'hash-991',
+ downloadUrl: 'https://example.com/991.webm',
+ createdAt: '2026-03-01T00:00:00Z',
+ updatedAt: '2026-03-01T00:00:00Z',
+ },
+ ],
+ } as unknown as VerseAudioRecording,
+ ];
+
+ renderPage();
+
+ expect(screen.getByText('Take 1 - Pericope - vv. 1-2')).toBeInTheDocument();
+ });
+
+ it('excludes covering pericope takes from Select as Draft when verse is in conflict', () => {
+ useAppStore.setState({
+ displayMode: 'verse',
+ userdetail: {
+ id: 1,
+ username: 'alice',
+ displayName: 'Alice',
+ email: 'alice@example.com',
+ role: 'user',
+ grants: [
+ {
+ orgId: 1,
+ projectId: 1,
+ roleId: 1,
+ roleName: 'Project Manager',
+ permissions: [],
+ },
+ ],
+ lastActiveOrgId: 1,
+ },
+ });
+
+ currentRecordings = [
+ {
+ ...mockRecordings[0],
+ conflictStatus: 'conflict',
+ activeTakeId: 11,
+ takes: [
+ mockRecordings[0].takes[0],
+ {
+ id: 112,
+ uploadedBy: 1,
+ contentType: 'audio/webm',
+ sizeBytes: 1000,
+ durationSeconds: 5,
+ contentHash: 'hash-112',
+ downloadUrl: 'https://example.com/112.webm',
+ createdAt: '2026-03-01T00:00:00Z',
+ updatedAt: '2026-03-01T00:00:00Z',
+ },
+ ],
+ },
+ {
+ id: 99,
+ projectUnitId: 1,
+ bibleTextId: 1,
+ uploadedBy: 1,
+ contentType: 'audio/webm',
+ sizeBytes: 1000,
+ durationSeconds: 10,
+ versionToken: 1,
+ downloadUrl: 'https://example.com/p.webm',
+ verseNumber: 1,
+ isPericope: true,
+ pericopeRange: [1, 2],
+ conflictStatus: 'clean',
+ activeTakeId: 991,
+ createdAt: '2026-03-01T00:00:00Z',
+ updatedAt: '2026-03-01T00:00:00Z',
+ takes: [
+ {
+ id: 991,
+ uploadedBy: 1,
+ contentType: 'audio/webm',
+ sizeBytes: 1000,
+ durationSeconds: 10,
+ contentHash: 'hash-991',
+ downloadUrl: 'https://example.com/991.webm',
+ createdAt: '2026-03-01T00:00:00Z',
+ updatedAt: '2026-03-01T00:00:00Z',
+ },
+ ],
+ } as unknown as VerseAudioRecording,
+ ];
+
+ renderPage();
+
+ // Select as Draft button should appear exactly once (for take 112 belonging to verse recording),
+ // and NOT for covering pericope take 991.
+ const selectButtons = screen.getAllByRole('button', { name: /select as draft/i });
+ expect(selectButtons).toHaveLength(1);
+ });
+
+ it('renders without crashing when there are no recordings or passages', () => {
+ currentRecordings = [];
+ renderPage();
+ expect(screen.getByText('No recorded takes found for this chapter.')).toBeInTheDocument();
+ });
+
+ it('displays both conflict dot and cross-view warning indicator in pericope view when both direct and verse conflicts exist', () => {
+ // Both direct pericope conflict and verse 1 conflict
+ currentRecordings = [
+ {
+ ...mockRecordings[0],
+ conflictStatus: 'conflict',
+ },
+ mockRecordings[1],
+ mockRecordings[2],
+ {
+ id: 99,
+ projectUnitId: 1,
+ bibleTextId: 1,
+ uploadedBy: 1,
+ contentType: 'audio/webm',
+ sizeBytes: 1000,
+ durationSeconds: 10,
+ versionToken: 1,
+ downloadUrl: 'https://example.com/p.webm',
+ verseNumber: 1,
+ isPericope: true,
+ pericopeRange: [1, 2],
+ conflictStatus: 'conflict',
+ activeTakeId: 991,
+ createdAt: '2026-03-01T00:00:00Z',
+ updatedAt: '2026-03-01T00:00:00Z',
+ takes: [
+ {
+ id: 991,
+ uploadedBy: 1,
+ contentType: 'audio/webm',
+ sizeBytes: 1000,
+ durationSeconds: 10,
+ contentHash: 'hash-991',
+ downloadUrl: 'https://example.com/991.webm',
+ createdAt: '2026-03-01T00:00:00Z',
+ updatedAt: '2026-03-01T00:00:00Z',
+ },
+ {
+ id: 992,
+ uploadedBy: 2,
+ contentType: 'audio/webm',
+ sizeBytes: 1000,
+ durationSeconds: 10,
+ contentHash: 'hash-992',
+ downloadUrl: 'https://example.com/992.webm',
+ createdAt: '2026-03-01T00:00:00Z',
+ updatedAt: '2026-03-01T00:00:00Z',
+ },
+ ],
+ } as unknown as VerseAudioRecording,
+ ];
+
+ renderPage();
+
+ // Cross-view indicator is present on the row
+ expect(screen.getByText('Conflict in verse 1')).toBeInTheDocument();
+
+ // Direct conflict banner and cross-view notice are present
+ expect(
+ screen.getByText('This passage has conflicting takes — select one to resolve.')
+ ).toBeInTheDocument();
+ expect(screen.getByText(/There is also a conflict recorded by verse\./)).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: /switch to verse view/i })).toBeInTheDocument();
+
+ // Direct pericope takes can still be resolved as draft
+ expect(screen.getAllByRole('button', { name: /select as draft/i })[0]).toBeInTheDocument();
+ });
+
+ it('displays both conflict dot and cross-view warning indicator in verse view when both verse direct conflict and covering pericope conflict exist', () => {
+ useAppStore.setState({ displayMode: 'verse' });
+
+ currentRecordings = [
+ {
+ ...mockRecordings[0],
+ conflictStatus: 'conflict',
+ takes: [
+ mockRecordings[0].takes[0],
+ {
+ id: 113,
+ uploadedBy: 2,
+ contentType: 'audio/webm',
+ sizeBytes: 1000,
+ durationSeconds: 10,
+ contentHash: 'hash-113',
+ downloadUrl: 'https://example.com/113.webm',
+ createdAt: '2026-03-01T00:00:00Z',
+ updatedAt: '2026-03-01T00:00:00Z',
+ },
+ ],
+ },
+ mockRecordings[1],
+ mockRecordings[2],
+ {
+ id: 99,
+ projectUnitId: 1,
+ bibleTextId: 1,
+ uploadedBy: 1,
+ contentType: 'audio/webm',
+ sizeBytes: 1000,
+ durationSeconds: 10,
+ versionToken: 1,
+ downloadUrl: 'https://example.com/p.webm',
+ verseNumber: 1,
+ isPericope: true,
+ pericopeRange: [1, 2],
+ conflictStatus: 'conflict',
+ activeTakeId: 991,
+ createdAt: '2026-03-01T00:00:00Z',
+ updatedAt: '2026-03-01T00:00:00Z',
+ takes: [
+ {
+ id: 991,
+ uploadedBy: 1,
+ contentType: 'audio/webm',
+ sizeBytes: 1000,
+ durationSeconds: 10,
+ contentHash: 'hash-991',
+ downloadUrl: 'https://example.com/991.webm',
+ createdAt: '2026-03-01T00:00:00Z',
+ updatedAt: '2026-03-01T00:00:00Z',
+ },
+ ],
+ } as unknown as VerseAudioRecording,
+ ];
+
+ renderPage();
+
+ // Cross-view indicator is present on verse 1 row (and verse 2 row covered by pericope 1-2)
+ expect(screen.getAllByText('Conflict in pericope vv. 1-2')[0]).toBeInTheDocument();
+
+ // Direct conflict banner and cross-view notice are present
+ expect(
+ screen.getByText('This passage has conflicting takes — select one to resolve.')
+ ).toBeInTheDocument();
+ expect(
+ screen.getByText(/There is also a conflict recorded as a pericope\./)
+ ).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: /switch to pericope view/i })).toBeInTheDocument();
+
+ // Direct verse takes can still be resolved as draft
+ expect(screen.getAllByRole('button', { name: /select as draft/i })[0]).toBeInTheDocument();
+ });
+
+ it('honestly labels cross-chapter pericopes showing continuation in the next chapter', () => {
+ currentPericopes = [
+ {
+ pericopeNumber: '1',
+ pericopeTitle: null,
+ verses: [
+ { verseNumber: 1, chapterNumber: 6 },
+ { verseNumber: 2, chapterNumber: 6 },
+ { verseNumber: 1, chapterNumber: 7 },
+ ],
+ },
+ ];
+
+ renderPage();
+
+ // The passage in the list should note continuation in ch. 7
+ expect(screen.getByText('Verses 1–2 (cont. in ch. 7)')).toBeInTheDocument();
+
+ // The StitchedAudioPlayer title should also note continuation
+ expect(
+ screen.getByText('Stitched from verse recordings, vv. 1-2 (cont. in ch. 7)')
+ ).toBeInTheDocument();
+ });
+});
diff --git a/src/features/audio/components/AudioReviewPage.tsx b/src/features/audio/components/AudioReviewPage.tsx
index 9022dfd8..7a13eebd 100644
--- a/src/features/audio/components/AudioReviewPage.tsx
+++ b/src/features/audio/components/AudioReviewPage.tsx
@@ -7,13 +7,19 @@ import { useTranslation } from 'react-i18next';
import { useChapterAudio } from '@/features/audio/hooks/useChapterAudio';
import { useResolveConflict } from '@/features/audio/hooks/useResolveConflict';
+import {
+ isPericopeRecording,
+ pericopeSpan,
+ useStitchedAudio,
+} from '@/features/audio/hooks/useStitchedAudio';
import { targetTextQueryOptions } from '@/features/bible/hooks/useBibleTarget';
import { useChapterPericopes } from '@/features/pericopes/hooks/useChapterPericopes';
import { useProjectUsers } from '@/features/projects/hooks/useProjectUsers';
import { getActiveGrants, isProjectManager } from '@/lib/grant-utils';
-import { type ProjectItem, type VerseAudioRecording } from '@/lib/types';
+import { type ProjectItem, type VerseAudioRecording, type VerseAudioTake } from '@/lib/types';
import { type DisplayMode, useAppStore } from '@/store/store';
+import StitchedAudioPlayer from './StitchedAudioPlayer';
import TakeCard, { SelectAsDraftButton } from './TakeCard';
// ---------------------------------------------------------------------------
@@ -39,6 +45,8 @@ interface PassageListItemProps {
isSelected: boolean;
/** True if this passage was previously conflicted but is now resolved */
isResolved: boolean;
+ /** Cross-view conflict warning label, e.g. "Conflict in verse 6" or "Conflict in pericope vv. 1-5" */
+ crossViewConflict?: string | null;
onClick: () => void;
}
@@ -47,34 +55,47 @@ const PassageListItem: React.FC = ({
dotState,
isSelected,
isResolved,
+ crossViewConflict,
onClick,
}) => {
const resolvedGreen = 'var(--success-subtle-foreground)';
+ const warningColor = 'var(--warning-subtle-foreground)';
return (
);
};
@@ -91,11 +112,19 @@ interface Passage {
verseNumbers: number[];
recordings: VerseAudioRecording[];
hasConflict: boolean;
+ hasDirectConflict: boolean;
wasConflicted: boolean;
+ crossViewConflict?: string | null;
+ isOppositeModeConflict?: boolean;
+ conflictRecordedMode?: 'verse' | 'pericope';
+ continuationNote?: string;
+ conflictedVerses?: number[];
}
const isPassage = (passage: Passage | null): passage is Passage => passage !== null;
+const EMPTY_VERSE_NUMBERS: number[] = [];
+
interface AudioReviewPageProps {
projectItem?: ProjectItem;
}
@@ -105,8 +134,17 @@ const AudioReviewPage: React.FC = ({ projectItem: propItem
const loaderData = routeApi.useLoaderData();
const projectItem = propItem ?? loaderData.projectItem;
const router = useRouter();
- const initialDisplayMode = useAppStore(state => state.displayMode);
- const [displayMode, setDisplayMode] = useState(initialDisplayMode);
+ const globalDisplayMode = useAppStore(state => state.displayMode);
+ const [pageDisplayModeOverride, setPageDisplayModeOverride] = useState(null);
+ const [prevGlobalDisplayMode, setPrevGlobalDisplayMode] = useState(globalDisplayMode);
+
+ if (prevGlobalDisplayMode !== globalDisplayMode) {
+ setPrevGlobalDisplayMode(globalDisplayMode);
+ setPageDisplayModeOverride(null);
+ }
+
+ const displayMode = pageDisplayModeOverride ?? globalDisplayMode;
+ const setDisplayMode = setPageDisplayModeOverride;
const userdetail = useAppStore(state => state.userdetail);
// Only PMs may resolve conflicts (spec #383).
@@ -144,59 +182,200 @@ const AudioReviewPage: React.FC = ({ projectItem: propItem
[projectUsers]
);
+ interface ReviewTake extends VerseAudioTake {
+ displayLabel?: string;
+ bibleTextId?: number;
+ isCoveringPericope?: boolean;
+ }
+
// ---------- Build passage list ----------
/**
* A "passage" is either a single verse (verse/chapter mode) or a pericope group.
- * Each entry carries the set of VerseAudioRecording rows that belong to it.
+ * Each entry carries the set of VerseAudioRecording rows that belong to it,
+ * plus cross-view conflict metadata.
*/
const passages: Passage[] = useMemo(() => {
const recordings = audioData?.items ?? [];
+ const pericopeRecordings = recordings.filter(isPericopeRecording);
+ const verseRecordings = recordings.filter(r => !isPericopeRecording(r));
if (displayMode === 'pericope' && pericopes && pericopes.length > 0) {
return pericopes
.map((group): Passage | null => {
- const groupVerseNums = new Set(group.verses.map(v => v.verseNumber));
- const groupRecordings = recordings.filter(r => groupVerseNums.has(r.verseNumber));
- if (groupRecordings.length === 0) return null;
+ // Only include verses belonging to the current chapter
+ const chapterVerses = group.verses.filter(
+ v => v.chapterNumber === projectItem.chapterNumber
+ );
+ if (chapterVerses.length === 0) return null;
- const startVerse = group.verses[0]?.verseNumber;
- const endVerse = group.verses[group.verses.length - 1]?.verseNumber;
- const label =
+ const groupVerseNums = new Set(chapterVerses.map(v => v.verseNumber));
+ const startVerse = chapterVerses[0]?.verseNumber;
+ const endVerse = chapterVerses[chapterVerses.length - 1]?.verseNumber;
+
+ // Direct pericope recordings
+ const directPericopeRecs = pericopeRecordings.filter(r => {
+ const span = pericopeSpan(r);
+ if (!span) return false;
+ return span[0] === startVerse && span[1] === endVerse;
+ });
+
+ // Verse recordings for verses in this pericope
+ const groupVerseRecs = verseRecordings.filter(r => groupVerseNums.has(r.verseNumber));
+
+ if (directPericopeRecs.length === 0 && groupVerseRecs.length === 0) return null;
+
+ const baseLabel =
startVerse === endVerse
? t('verseLabel', 'Verse {{verse}}', { verse: startVerse })
: t('versesLabel', 'Verses {{start}}–{{end}}', { start: startVerse, end: endVerse });
- const hasConflictNow = groupRecordings.some(isConflict);
- // 'resolved' is set by the API when the conflict was explicitly resolved
- // via PUT /resolve — unambiguous, no heuristic needed.
- const wasConflicted = groupRecordings.some(isResolved);
+ const prevChapterVerses = group.verses.filter(
+ v => v.chapterNumber < projectItem.chapterNumber
+ );
+ const nextChapterVerses = group.verses.filter(
+ v => v.chapterNumber > projectItem.chapterNumber
+ );
+
+ let continuationNote: string | undefined;
+ if (prevChapterVerses.length > 0 && nextChapterVerses.length > 0) {
+ const prevCh = prevChapterVerses[prevChapterVerses.length - 1]?.chapterNumber;
+ const nextCh = nextChapterVerses[0]?.chapterNumber;
+ continuationNote = t(
+ 'pericopeContFromAndInNote',
+ '(cont. from ch. {{prevCh}}, in ch. {{nextCh}})',
+ { prevCh, nextCh }
+ );
+ } else if (nextChapterVerses.length > 0) {
+ const nextCh = nextChapterVerses[0]?.chapterNumber;
+ continuationNote = t('pericopeContInNextNote', '(cont. in ch. {{nextCh}})', {
+ nextCh,
+ });
+ } else if (prevChapterVerses.length > 0) {
+ const prevCh = prevChapterVerses[prevChapterVerses.length - 1]?.chapterNumber;
+ continuationNote = t('pericopeContFromPrevNote', '(cont. from ch. {{prevCh}})', {
+ prevCh,
+ });
+ }
+
+ const label = continuationNote ? `${baseLabel} ${continuationNote}` : baseLabel;
+
+ const hasDirectConflict = directPericopeRecs.some(isConflict);
+ const conflictedVerses = groupVerseRecs
+ .filter(isConflict)
+ .map(r => r.verseNumber)
+ .sort((a, b) => a - b);
+ const hasVerseConflict = conflictedVerses.length > 0;
+
+ const hasConflictNow = hasDirectConflict || hasVerseConflict;
+ const wasConflicted =
+ directPericopeRecs.some(isResolved) || groupVerseRecs.some(isResolved);
+
+ let crossViewConflict: string | null = null;
+ let isOppositeModeConflict = false;
+ let conflictRecordedMode: 'verse' | 'pericope' | undefined;
+
+ if (hasVerseConflict) {
+ conflictRecordedMode = 'verse';
+ crossViewConflict =
+ conflictedVerses.length === 1
+ ? t('conflictInVerse', 'Conflict in verse {{verse}}', {
+ verse: conflictedVerses[0],
+ })
+ : t('conflictInVerses', 'Conflict in verses {{verses}}', {
+ verses: conflictedVerses.join(', '),
+ });
+ if (!hasDirectConflict) {
+ isOppositeModeConflict = true;
+ }
+ }
+
+ const passageRecs = directPericopeRecs.length > 0 ? directPericopeRecs : groupVerseRecs;
return {
id: `pericope-${group.pericopeNumber}`,
label,
- verseNumbers: group.verses.map(v => v.verseNumber),
- recordings: groupRecordings,
+ verseNumbers: chapterVerses.map(v => v.verseNumber),
+ recordings: passageRecs,
hasConflict: hasConflictNow,
+ hasDirectConflict,
wasConflicted,
+ crossViewConflict,
+ isOppositeModeConflict,
+ conflictRecordedMode,
+ continuationNote,
+ conflictedVerses,
};
})
.filter(isPassage);
}
- // Verse / chapter mode: one row per verse
- return recordings.map(
- (r): Passage => ({
- id: `verse-${r.verseNumber}`,
- label: t('verseLabel', 'Verse {{verse}}', { verse: r.verseNumber }),
- verseNumbers: [r.verseNumber],
- recordings: [r],
- hasConflict: isConflict(r),
- // 'resolved' status is set by the API on successful conflict resolution.
- wasConflicted: isResolved(r),
- })
- );
- }, [audioData, displayMode, pericopes, t]);
+ // Verse / chapter mode:
+ // Collect all verse numbers that have verse recordings or are covered by pericope recordings
+ const verseNumSet = new Set();
+ for (const r of verseRecordings) {
+ verseNumSet.add(r.verseNumber);
+ }
+ for (const pr of pericopeRecordings) {
+ const span = pericopeSpan(pr);
+ if (!span) continue;
+ const [start, end] = span;
+ for (let v = start; v <= end; v++) {
+ verseNumSet.add(v);
+ }
+ }
+
+ const sortedVerses = Array.from(verseNumSet).sort((a, b) => a - b);
+
+ return sortedVerses.map((v): Passage => {
+ const r = verseRecordings.find(rec => rec.verseNumber === v);
+
+ // Check if this verse is covered by a pericope recording
+ const coveringPericope = pericopeRecordings.find(pr => {
+ const span = pericopeSpan(pr);
+ if (!span) return false;
+ const [start, end] = span;
+ return v >= start && v <= end;
+ });
+
+ const hasDirectConflict = Boolean(r && isConflict(r));
+ const hasPericopeConflict = Boolean(coveringPericope && isConflict(coveringPericope));
+ const hasConflictNow = hasDirectConflict || hasPericopeConflict;
+ const wasConflicted =
+ Boolean(r && isResolved(r)) || Boolean(coveringPericope && isResolved(coveringPericope));
+
+ let crossViewConflict: string | null = null;
+ let isOppositeModeConflict = false;
+ let conflictRecordedMode: 'verse' | 'pericope' | undefined;
+
+ if (hasPericopeConflict && coveringPericope) {
+ const span = pericopeSpan(coveringPericope);
+ if (span) {
+ conflictRecordedMode = 'pericope';
+ const [start, end] = span;
+ crossViewConflict = t('conflictInPericope', 'Conflict in pericope vv. {{range}}', {
+ range: `${start}-${end}`,
+ });
+ if (!hasDirectConflict) {
+ isOppositeModeConflict = true;
+ }
+ }
+ }
+
+ return {
+ id: `verse-${v}`,
+ label: t('verseLabel', 'Verse {{verse}}', { verse: v }),
+ verseNumbers: [v],
+ recordings: r ? [r] : [],
+ hasConflict: hasConflictNow,
+ hasDirectConflict,
+ wasConflicted,
+ crossViewConflict,
+ isOppositeModeConflict,
+ conflictRecordedMode,
+ };
+ });
+ }, [audioData, displayMode, pericopes, projectItem.chapterNumber, t]);
// ---------- All-resolved chip ----------
@@ -215,23 +394,101 @@ const AudioReviewPage: React.FC = ({ projectItem: propItem
return passages.find(p => p.id === selectedPassageId) ?? passages[0];
}, [passages, selectedPassageId]);
- // All takes for the selected passage (union of all recordings in the passage).
- // Pericope mode may have multiple recordings — we flatten all their takes.
- const selectedTakes = useMemo(
- () => (selectedPassage?.recordings ?? []).flatMap(r => r.takes),
- [selectedPassage]
- );
+ const effectivePassage = selectedPassage ?? passages[0];
+
+ const handleSwitchToVerseView = () => {
+ const targetVerse = effectivePassage.conflictedVerses?.[0] ?? effectivePassage.verseNumbers[0];
+ if (typeof targetVerse === 'number') {
+ setSelectedPassageId(`verse-${targetVerse}`);
+ }
+ setDisplayMode('verse');
+ };
+
+ const handleSwitchToPericopeView = () => {
+ const v = effectivePassage.verseNumbers[0];
+ if (typeof v === 'number' && pericopes) {
+ const coveringGroup = pericopes.find(group =>
+ group.verses.some(
+ gv => gv.chapterNumber === projectItem.chapterNumber && gv.verseNumber === v
+ )
+ );
+ if (coveringGroup) {
+ setSelectedPassageId(`pericope-${coveringGroup.pericopeNumber}`);
+ }
+ }
+ setDisplayMode('pericope');
+ };
+
+ // ---------- Stitched audio calculation for pericope mode ----------
+
+ const stitchedAudio = useStitchedAudio({
+ verseNumbers: selectedPassage?.verseNumbers ?? EMPTY_VERSE_NUMBERS,
+ recordings: audioData?.items ?? [],
+ });
// Map each take.id to its parent recording's bibleTextId
const takeBibleTextMap = useMemo(() => {
const map = new Map();
- for (const recording of selectedPassage?.recordings ?? []) {
+ const recordings = audioData?.items ?? [];
+ for (const recording of recordings) {
for (const take of recording.takes) {
map.set(take.id, recording.bibleTextId);
}
}
return map;
- }, [selectedPassage]);
+ }, [audioData]);
+
+ // All takes for the selected passage
+ const selectedTakes = useMemo((): ReviewTake[] => {
+ if (!selectedPassage) return [];
+ const recordings = audioData?.items ?? [];
+ const pericopeRecordings = recordings.filter(isPericopeRecording);
+
+ if (displayMode !== 'pericope') {
+ const v = selectedPassage.verseNumbers[0];
+ const verseTakes: ReviewTake[] = (selectedPassage.recordings[0]?.takes ?? []).map(t => ({
+ ...t,
+ bibleTextId: selectedPassage.recordings[0]?.bibleTextId,
+ isCoveringPericope: false,
+ }));
+
+ // Find pericope recordings covering this verse
+ const coveringPericopes = pericopeRecordings.filter(pr => {
+ const span = pericopeSpan(pr);
+ if (!span) return false;
+ const [start, end] = span;
+ return v >= start && v <= end;
+ });
+
+ const pericopeTakes: ReviewTake[] = [];
+ for (const pr of coveringPericopes) {
+ // "When that pericope is in conflict, do not list its takes under the verses;
+ // the cross-view conflict indicator and message apply instead."
+ if (isConflict(pr)) continue;
+
+ const span = pericopeSpan(pr);
+ if (!span) continue;
+ const [start, end] = span;
+ const range = `${start}-${end}`;
+ pr.takes.forEach((take, idx) => {
+ pericopeTakes.push({
+ ...take,
+ displayLabel: t('takePericopeLabel', 'Take {{takeNumber}} - Pericope - vv. {{range}}', {
+ takeNumber: idx + 1,
+ range,
+ }),
+ bibleTextId: pr.bibleTextId,
+ isCoveringPericope: true,
+ });
+ });
+ }
+
+ return [...verseTakes, ...pericopeTakes];
+ }
+
+ // Pericope mode
+ return selectedPassage.recordings.flatMap(r => r.takes);
+ }, [selectedPassage, displayMode, audioData, t]);
// Show contributor names only when the passage has takes from more than one contributor (spec #383)
const showContributor = useMemo(() => {
@@ -241,16 +498,38 @@ const AudioReviewPage: React.FC = ({ projectItem: propItem
/**
* Active take id — look across all recordings in the passage.
- * In verse mode there's always exactly one recording; in pericope mode
- * there can be many (one per verse). We find the first take id that is
- * marked active in any of those recordings.
*/
const activeTakeId = useMemo(() => {
- for (const r of selectedPassage?.recordings ?? []) {
- if (r.activeTakeId !== null) return r.activeTakeId;
+ if (displayMode === 'pericope') {
+ if (stitchedAudio.hasActivePericopeTake && stitchedAudio.activePericopeTake) {
+ return stitchedAudio.activePericopeTake.id;
+ }
+ for (const r of selectedPassage?.recordings ?? []) {
+ if (r.activeTakeId !== null) return r.activeTakeId;
+ }
+ return null;
+ }
+
+ // Verse mode
+ const vRec = selectedPassage?.recordings[0];
+ if (vRec && vRec.activeTakeId !== null) return vRec.activeTakeId;
+
+ // Check if covering pericope has active take
+ const pericopeRecordings = (audioData?.items ?? []).filter(isPericopeRecording);
+ const v = selectedPassage?.verseNumbers[0];
+ if (v !== undefined) {
+ const coveringPericope = pericopeRecordings.find(pr => {
+ const span = pericopeSpan(pr);
+ if (!span) return false;
+ const [start, end] = span;
+ return v >= start && v <= end && !isConflict(pr);
+ });
+ if (coveringPericope && coveringPericope.activeTakeId !== null) {
+ return coveringPericope.activeTakeId;
+ }
}
return null;
- }, [selectedPassage]);
+ }, [selectedPassage, displayMode, stitchedAudio, audioData]);
const selectedHasConflict = selectedPassage?.hasConflict ?? false;
@@ -308,8 +587,6 @@ const AudioReviewPage: React.FC = ({ projectItem: propItem
);
}
- const effectivePassage = selectedPassage ?? passages[0];
-
return (
{/* ── Page header ─────────────────────────────────────── */}
@@ -363,16 +640,20 @@ const AudioReviewPage: React.FC = ({ projectItem: propItem
style={{ scrollbarGutter: 'stable' }}
>
{passages.map(passage => {
- // Dot: orange = currently in conflict; green = API confirmed 'resolved'; none otherwise
+ // Dot: orange = currently in direct conflict in this view; green = API confirmed 'resolved'; none otherwise
let dotState: 'conflict' | 'resolved' | null = null;
- if (passage.hasConflict) dotState = 'conflict';
- else if (passage.wasConflicted) dotState = 'resolved';
+ if (passage.hasDirectConflict) {
+ dotState = 'conflict';
+ } else if (passage.wasConflicted && !passage.hasConflict) {
+ dotState = 'resolved';
+ }
const isPassageResolved = dotState === 'resolved';
return (
= ({ projectItem: propItem
: t('recordedTakes', 'Recorded Takes')}
- {selectedHasConflict
- ? t(
- 'conflictingTakesDesc',
- 'Select one take as the active draft. All recordings are retained.'
- )
- : effectivePassage.wasConflicted
+ {displayMode === 'pericope' && stitchedAudio.hasOnlyPerVerseRecordings
+ ? t('recordedByVersePlayedAsOne', 'Recorded by verse. Played as one recording.')
+ : selectedHasConflict
? t(
- 'resolvedTakesDesc',
- 'This conflict has been resolved. All recordings are retained.'
+ 'conflictingTakesDesc',
+ 'Select one take as the active draft. All recordings are retained.'
)
- : t('recordedTakesDesc', 'Listen to every recorded take for this passage.')}
+ : effectivePassage.wasConflicted
+ ? t(
+ 'resolvedTakesDesc',
+ 'This conflict has been resolved. All recordings are retained.'
+ )
+ : t('recordedTakesDesc', 'Listen to every recorded take for this passage.')}
@@ -425,9 +708,75 @@ const AudioReviewPage: React.FC = ({ projectItem: propItem
style={{ color: 'var(--warning-border)' }}
/>
- {t(
- 'conflictBanner',
- 'This passage has conflicting takes — select one to resolve.'
+ {effectivePassage.hasDirectConflict ? (
+ <>
+ {t(
+ 'conflictBanner',
+ 'This passage has conflicting takes — select one to resolve.'
+ )}
+ {effectivePassage.crossViewConflict && (
+
+ {effectivePassage.conflictRecordedMode === 'verse' ? (
+ <>
+ {t(
+ 'conflictAlsoRecordedByVerse',
+ 'There is also a conflict recorded by verse.'
+ )}{' '}
+ {' '}
+ {t('toResolveIt', 'to resolve it.')}
+ >
+ ) : (
+ <>
+ {t(
+ 'conflictAlsoRecordedAsPericope',
+ 'There is also a conflict recorded as a pericope.'
+ )}{' '}
+ {' '}
+ {t('toResolveIt', 'to resolve it.')}
+ >
+ )}
+
+ )}
+ >
+ ) : effectivePassage.conflictRecordedMode === 'verse' ? (
+ <>
+ {t('conflictRecordedByVerseLead', 'This conflict was recorded by verse.')}{' '}
+ {' '}
+ {t('toResolveIt', 'to resolve it.')}
+ >
+ ) : (
+ <>
+ {t(
+ 'conflictRecordedAsPericopeLead',
+ 'This conflict was recorded as a pericope.'
+ )}{' '}
+ {' '}
+ {t('toResolveIt', 'to resolve it.')}
+ >
)}
@@ -439,23 +788,26 @@ const AudioReviewPage: React.FC = ({ projectItem: propItem
className='min-h-0 flex-1 overflow-y-auto px-6 pb-6'
style={{ scrollbarGutter: 'stable' }}
>
- {/* TODO(fluent-api#377): Spec: "List takes as normal for a pericope recorded as a pericope".
- Currently, recordings are stored at verse grain, so pericope view prompts the user
- to switch to Verse view to review and resolve individual verse takes. Once the upstream
- granularity field lands (fluent-api#377), check recording granularity here to list
- pericope-grain takes directly. */}
- {displayMode === 'pericope' ? (
-
- {t('recordedByVerse', 'Recorded by verse.')}{' '}
- {' '}
- {t('toReviewTheseTakes', 'to review these takes.')}
-