diff --git a/src/components/player/TTSPlayer.tsx b/src/components/player/TTSPlayer.tsx
index 13054b4..c38dc23 100644
--- a/src/components/player/TTSPlayer.tsx
+++ b/src/components/player/TTSPlayer.tsx
@@ -62,7 +62,7 @@ export default function TTSPlayer({ currentPage, numPages }: {
onClick={togglePlay}
className="relative p-1.5 rounded-md text-foreground hover:bg-offbase transition-all duration-200 focus:outline-none h-8 w-8 flex items-center justify-center transform ease-in-out hover:scale-[1.09] hover:text-accent"
aria-label={isPlaying ? 'Pause' : 'Play'}
- disabled={isProcessing}
+ disabled={isProcessing && !isPlaying}
>
{isPlaying ? : }
diff --git a/src/contexts/TTSContext.tsx b/src/contexts/TTSContext.tsx
index 61e4eac..a4b92c6 100644
--- a/src/contexts/TTSContext.tsx
+++ b/src/contexts/TTSContext.tsx
@@ -433,6 +433,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
const [currentSentenceAlignment, setCurrentSentenceAlignment] = useState();
const [currentWordIndex, setCurrentWordIndex] = useState(null);
const isPlayingRef = useRef(false);
+ const pauseEpochRef = useRef(0);
const sentencesRef = useRef([]);
const currentIndexRef = useRef(0);
const setTextRef = useRef<(text: string, options?: boolean | SetTextOptions) => void>(() => { });
@@ -642,14 +643,21 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
}
}, [activeHowl, clearRateWatchdog]);
+ const recordManualPause = useCallback(() => {
+ // Cancel any queued auto-resume intent and mark an explicit user pause.
+ resumeAfterLocationChangeRef.current = false;
+ pauseEpochRef.current += 1;
+ }, []);
+
/**
* Pauses the current audio playback
* Used for external control of playback state
*/
const pause = useCallback(() => {
+ recordManualPause();
pauseActiveHowl();
setIsPlaying(false);
- }, [pauseActiveHowl]);
+ }, [pauseActiveHowl, recordManualPause]);
/**
* Navigates to a specific location in the document
@@ -882,6 +890,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
if (handleBlankSection(workingText)) return;
const shouldPause = normalizedOptions.shouldPause ?? false;
+ const pauseEpochAtStart = pauseEpochRef.current;
const pendingAutoResume = resumeAfterLocationChangeRef.current;
const shouldResumePlayback = !shouldPause && (isPlaying || pendingAutoResume);
if (shouldPause || pendingAutoResume) {
@@ -968,7 +977,8 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
setIsProcessing(false);
// Restore playback state if needed
- if (shouldResumePlayback) {
+ // Respect explicit pauses that happened while sentence splitting was in flight.
+ if (shouldResumePlayback && pauseEpochRef.current === pauseEpochAtStart) {
setIsPlaying(true);
}
})
@@ -990,6 +1000,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
*/
const togglePlay = useCallback(() => {
if (isPlaying) {
+ recordManualPause();
pauseActiveHowl();
setIsPlaying(false);
return;
@@ -1014,7 +1025,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
}
setIsPlaying(true);
- }, [activeHowl, applyPlaybackRateToHowl, isPlaying, pauseActiveHowl, unlockPlaybackOnUserGesture]);
+ }, [activeHowl, applyPlaybackRateToHowl, isPlaying, pauseActiveHowl, recordManualPause, unlockPlaybackOnUserGesture]);
/**
diff --git a/tests/accessibility.spec.ts b/tests/accessibility.spec.ts
index 3370483..f3e4504 100644
--- a/tests/accessibility.spec.ts
+++ b/tests/accessibility.spec.ts
@@ -5,6 +5,7 @@ import {
ensureDocumentsListed,
uploadAndDisplay,
expectProcessingTransition,
+ pauseTTSAndVerify,
} from './helpers';
test.describe('Accessibility smoke', () => {
@@ -81,8 +82,6 @@ test.describe('Accessibility smoke', () => {
await playBtn.click();
await expectProcessingTransition(page);
await expect(page.getByRole('button', { name: 'Pause' })).toBeVisible();
-
- await page.getByRole('button', { name: 'Pause' }).click();
- await expect(page.getByRole('button', { name: 'Play' })).toBeVisible({ timeout: 10000 });
+ await pauseTTSAndVerify(page);
});
});
diff --git a/tests/helpers.ts b/tests/helpers.ts
index 34de9e5..9c6ccd0 100644
--- a/tests/helpers.ts
+++ b/tests/helpers.ts
@@ -190,11 +190,23 @@ export async function playTTSAndWaitForASecond(page: Page, fileName: string) {
* Pause TTS playback and verify paused state
*/
export async function pauseTTSAndVerify(page: Page) {
- // Click pause to stop playback
- await page.getByRole('button', { name: 'Pause' }).click();
+ const ttsbar = page.locator('[data-app-ttsbar]');
+ const pauseButton = ttsbar.getByRole('button', { name: 'Pause' }).first();
+ await expect(pauseButton).toBeVisible({ timeout: 15000 });
+ await expect(pauseButton).toBeEnabled({ timeout: 15000 });
- // Check for play button to be visible
- await expect(page.getByRole('button', { name: 'Play' })).toBeVisible({ timeout: 10000 });
+ // Retry pause because Firefox can race with transient processing transitions.
+ for (let attempt = 0; attempt < 3; attempt++) {
+ await pauseButton.click();
+ try {
+ await expect(ttsbar.getByRole('button', { name: 'Play' }).first()).toBeVisible({ timeout: 5000 });
+ await expectMediaState(page, 'paused');
+ return;
+ } catch {
+ if (attempt === 2) throw new Error('Failed to pause TTS playback after retries');
+ await page.waitForTimeout(250);
+ }
+ }
}
/**
@@ -318,9 +330,11 @@ export async function dispatchHtml5DragAndDrop(page: Page, source: Locator, targ
// Assert a document link containing the given filename appears in the list
export async function expectDocumentListed(page: Page, fileName: string) {
- await expect(
- page.getByRole('link', { name: new RegExp(escapeRegExp(fileName), 'i') }).first()
- ).toBeVisible({ timeout: 10000 });
+ const link = page.getByRole('link', { name: new RegExp(escapeRegExp(fileName), 'i') }).first();
+ await expect
+ .poll(async () => link.count(), { timeout: 20000 })
+ .toBeGreaterThan(0);
+ await expect(link).toBeVisible({ timeout: 15000 });
}
// Assert a document link containing the given filename does NOT exist
@@ -334,6 +348,7 @@ export async function expectNoDocumentLink(page: Page, fileName: string) {
export async function uploadFiles(page: Page, ...fileNames: string[]) {
for (const name of fileNames) {
await uploadFile(page, name);
+ await expectDocumentListed(page, name);
}
}
diff --git a/tests/play.spec.ts b/tests/play.spec.ts
index b9bc0c8..e9a6b3a 100644
--- a/tests/play.spec.ts
+++ b/tests/play.spec.ts
@@ -1,22 +1,15 @@
-import { test, expect } from '@playwright/test';
+import { test, expect, type Page } from '@playwright/test';
import {
setupTest,
playTTSAndWaitForASecond,
+ pauseTTSAndVerify,
openVoicesMenu,
selectVoiceAndAssertPlayback,
expectMediaState,
expectProcessingTransition,
} from './helpers';
-// Single-spec helpers kept local instead of living in shared helpers:
-async function pauseTTSAndVerify(page: any) {
- // Click pause to stop playback
- await page.getByRole('button', { name: 'Pause' }).click();
- // Check for play button to be visible
- await expect(page.getByRole('button', { name: 'Play' })).toBeVisible({ timeout: 10000 });
-}
-
-async function openSpeedPopover(page: any) {
+async function openSpeedPopover(page: Page) {
const ttsbar = page.locator('[data-app-ttsbar]');
const buttons = ttsbar.getByRole('button');
// Heuristic: the Speed control is the first button in the TTS bar and shows something like "1x"
@@ -27,7 +20,7 @@ async function openSpeedPopover(page: any) {
await page.waitForSelector('input[type="range"]', { timeout: 10000 });
}
-async function changeNativeSpeedAndAssert(page: any, newSpeed: number) {
+async function changeNativeSpeedAndAssert(page: Page, newSpeed: number) {
await openSpeedPopover(page);
const slider = page.locator('input[type="range"]').first();