Skip to content

Commit

Permalink
[AudioPlayer] Fix the touch-screen long-press behavior (#3065)
Browse files Browse the repository at this point in the history
  • Loading branch information
imnasnainaec authored Apr 25, 2024
1 parent 87bcb10 commit 576733e
Show file tree
Hide file tree
Showing 3 changed files with 192 additions and 7 deletions.
40 changes: 34 additions & 6 deletions src/components/Pronunciations/AudioPlayer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,13 @@ import { PronunciationsStatus } from "components/Pronunciations/Redux/Pronunciat
import { StoreState } from "types";
import { useAppDispatch, useAppSelector } from "types/hooks";

/** Number of ms for a touchscreen press to be considered a long-press.
* 600 ms is too short: it can still register as a click. */
export const longPressDelay = 700;

export const playButtonId = (fileName: string): string => `audio-${fileName}`;
export const playMenuId = "play-menu";

interface PlayerProps {
audio: Pronunciation;
deleteAudio?: (fileName: string) => void;
Expand All @@ -55,6 +62,9 @@ export default function AudioPlayer(props: PlayerProps): ReactElement {
);
const [anchor, setAnchor] = useState<HTMLElement | undefined>();
const [deleteConf, setDeleteConf] = useState(false);
const [longPressTarget, setLongPressTarget] = useState<
(EventTarget & HTMLButtonElement) | undefined
>();
const [speaker, setSpeaker] = useState<Speaker | undefined>();
const [speakerDialog, setSpeakerDialog] = useState(false);

Expand Down Expand Up @@ -86,6 +96,17 @@ export default function AudioPlayer(props: PlayerProps): ReactElement {
}
}, [audio, dispatchReset, isPlaying]);

// When pressed, set a timer for a long-press.
// https://stackoverflow.com/questions/48048957/add-a-long-press-event-in-react
useEffect(() => {
const timerId = longPressTarget
? setTimeout(() => setAnchor(longPressTarget), longPressDelay)
: undefined;
return () => {
clearTimeout(timerId);
};
}, [longPressTarget]);

function togglePlay(): void {
if (!isPlaying) {
dispatch(playing(props.audio.fileName));
Expand Down Expand Up @@ -125,15 +146,21 @@ export default function AudioPlayer(props: PlayerProps): ReactElement {

/** If audio can be deleted or speaker changed, a touchscreen press should open an
* options menu instead of the context menu. */
function handleTouch(e: TouchEvent<HTMLButtonElement>): void {
function handleTouchStart(e: TouchEvent<HTMLButtonElement>): void {
if (canChangeSpeaker || canDeleteAudio) {
// Temporarily disable context menu since some browsers
// interpret a long-press touch as a right-click.
disableContextMenu();
setAnchor(e.currentTarget);
setLongPressTarget(e.currentTarget);
}
}

/** When a touch ends, restore the context menu and cancel the long-press timer. */
function handleTouchEnd(): void {
enableContextMenu();
setLongPressTarget(undefined);
}

async function handleOnSelect(speaker?: Speaker): Promise<void> {
if (canChangeSpeaker) {
await props.updateAudioSpeaker!(speaker?.id);
Expand Down Expand Up @@ -194,6 +221,7 @@ export default function AudioPlayer(props: PlayerProps): ReactElement {
return (
<>
<Tooltip
disableTouchListener // Conflicts with our long-press menu.
title={<MultilineTooltipTitle lines={tooltipTexts} />}
placement="top"
>
Expand All @@ -202,19 +230,19 @@ export default function AudioPlayer(props: PlayerProps): ReactElement {
onAuxClick={handleOnAuxClick}
onClick={deleteOrTogglePlay}
onMouseDown={handleOnMouseDown}
onTouchStart={handleTouch}
onTouchEnd={enableContextMenu}
onTouchStart={handleTouchStart}
onTouchEnd={handleTouchEnd}
aria-label="play"
disabled={props.disabled}
id={`audio-${props.audio.fileName}`}
id={playButtonId(props.audio.fileName)}
size={props.size || "large"}
>
{icon}
</IconButton>
</Tooltip>
<Menu
TransitionComponent={Fade}
id="play-menu"
id={playMenuId}
anchorEl={anchor}
open={Boolean(anchor)}
onClose={handleMenuOnClose}
Expand Down
6 changes: 5 additions & 1 deletion src/components/Pronunciations/RecorderIcon.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,11 @@ export default function RecorderIcon(props: RecorderIconProps): ReactElement {
}

return (
<Tooltip title={t("pronunciations.recordTooltip")} placement="top">
<Tooltip
disableTouchListener // Distracting when already recording with a long-press.
placement="top"
title={t("pronunciations.recordTooltip")}
>
<IconButton
aria-label="record"
disabled={props.disabled}
Expand Down
153 changes: 153 additions & 0 deletions src/components/Pronunciations/tests/AudioPlayer.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
import { type TouchEvent } from "react";
import { Provider } from "react-redux";
import { type ReactTestRenderer, act, create } from "react-test-renderer";
import configureMockStore from "redux-mock-store";

import { defaultState } from "components/App/DefaultState";
import AudioPlayer, {
longPressDelay,
playButtonId,
playMenuId,
} from "components/Pronunciations/AudioPlayer";
import { PronunciationsStatus } from "components/Pronunciations/Redux/PronunciationsReduxTypes";
import { type StoreState } from "types";
import { newPronunciation } from "types/word";

// Mock out Menu to avoid issues with setting its anchor.
jest.mock("@mui/material", () => {
return {
...jest.requireActual("@mui/material"),
Menu: (props: any) => <div {...props} />,
};
});

jest.mock("backend", () => ({
getSpeaker: () => mockGetSpeaker(),
}));
jest.mock("types/hooks", () => {
return {
...jest.requireActual("types/hooks"),
useAppDispatch: () => mockDispatch,
};
});

const mockCanDeleteAudio = jest.fn();
const mockDispatch = jest.fn((action: any) => action);
const mockGetSpeaker = jest.fn();

let testRenderer: ReactTestRenderer;

const mockFileName = "speech.mp3";
const mockId = playButtonId(mockFileName);
const mockPronunciation = newPronunciation(mockFileName);
const mockStore = configureMockStore()(mockPlayingState());
const mockTouchEvent: Partial<TouchEvent<HTMLButtonElement>> = {
currentTarget: {} as HTMLButtonElement,
};

function mockPlayingState(fileName = ""): Partial<StoreState> {
return {
...defaultState,
pronunciationsState: {
fileName,
status: PronunciationsStatus.Inactive,
wordId: "",
},
};
}

function renderAudioPlayer(canDelete = false): void {
act(() => {
testRenderer = create(
<Provider store={mockStore}>
<AudioPlayer
audio={mockPronunciation}
deleteAudio={canDelete ? mockCanDeleteAudio : undefined}
/>
</Provider>
);
});
}

beforeEach(() => {
jest.clearAllMocks();
jest.clearAllTimers();
});

describe("Pronunciations", () => {
it("dispatches on play", () => {
renderAudioPlayer();
expect(mockDispatch).not.toHaveBeenCalled();
const playButton = testRenderer.root.findByProps({ id: mockId });
act(() => {
playButton.props.onClick();
});
expect(mockDispatch).toHaveBeenCalledTimes(1);
});

it("opens the menu on long-press", () => {
// Provide deleteAudio prop so that menu is available
renderAudioPlayer(true);

// Use a mock timer to control the length of the press
jest.useFakeTimers();

const playButton = testRenderer.root.findByProps({ id: mockId });
const playMenu = testRenderer.root.findByProps({ id: playMenuId });

// Start a press and advance the timer just shy of the long-press time
expect(playMenu.props.open).toBeFalsy();
act(() => {
playButton.props.onTouchStart(mockTouchEvent);
});
expect(playMenu.props.open).toBeFalsy();
act(() => {
jest.advanceTimersByTime(longPressDelay - 1);
});
expect(playMenu.props.open).toBeFalsy();

// Advance the timer just past the long-press time
act(() => {
jest.advanceTimersByTime(2);
});
expect(playMenu.props.open).toBeTruthy();

// Make sure the menu stays open and no play is dispatched
act(() => {
playButton.props.onTouchEnd();
jest.runAllTimers();
});
expect(playMenu.props.open).toBeTruthy();
expect(mockDispatch).not.toHaveBeenCalled();
});

it("doesn't open the menu on short-press", () => {
// Provide deleteAudio prop so that menu is available
renderAudioPlayer(true);

// Use a mock timer to control the length of the press
jest.useFakeTimers();

const playButton = testRenderer.root.findByProps({ id: mockId });
const playMenu = testRenderer.root.findByProps({ id: playMenuId });

// Press the button and advance the timer, but end press before the long-press time
expect(playMenu.props.open).toBeFalsy();
act(() => {
playButton.props.onTouchStart(mockTouchEvent);
});
expect(playMenu.props.open).toBeFalsy();
act(() => {
jest.advanceTimersByTime(longPressDelay - 1);
});
expect(playMenu.props.open).toBeFalsy();
act(() => {
playButton.props.onTouchEnd();
});
expect(playMenu.props.open).toBeFalsy();
act(() => {
jest.advanceTimersByTime(2);
});
expect(playMenu.props.open).toBeFalsy();
});
});

0 comments on commit 576733e

Please sign in to comment.