Skip to content

Commit 94c619b

Browse files
authored
Replay speaking card (#23)
* Avoid reporting about unsupported Speech synthesis API on old Android * Allow to replay speaking card
1 parent 8eed792 commit 94c619b

File tree

7 files changed

+67
-27
lines changed

7 files changed

+67
-27
lines changed

src/lib/typescript/enum-values.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,5 +9,5 @@ export function enumValues<E extends EnumLike>(enumObject: E): E[keyof E][] {
99
export function enumEntries<E extends EnumLike>(
1010
enumObject: E,
1111
): [keyof E, E[keyof E]][] {
12-
return Object.entries(enumObject) as any
12+
return Object.entries(enumObject) as any;
1313
}

src/lib/voice-playback/speak.ts

Lines changed: 5 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,3 @@
1-
import { reportHandledErrorOnce } from "../rollbar/rollbar.tsx";
2-
31
export enum SpeakLanguageEnum {
42
USEnglish = "en-US",
53
Italian = "it-IT",
@@ -16,6 +14,7 @@ export enum SpeakLanguageEnum {
1614
Japanese = "ja-JP",
1715
Romanian = "ro-RO",
1816
Portuguese = "pt-PT",
17+
BrazilianPortuguese = "pt-BR",
1918
Thai = "th-TH",
2019
Croatian = "hr-HR",
2120
Slovak = "sk-SK",
@@ -45,15 +44,12 @@ export const languageKeyToHuman = (str: string): string => {
4544
return str.replace(/([A-Z])/g, " $1").trim();
4645
};
4746

48-
export const speak = (text: string, language: SpeakLanguageEnum) => {
49-
const isSpeechSynthesisSupported =
50-
"speechSynthesis" in window &&
51-
typeof SpeechSynthesisUtterance !== "undefined";
47+
export const isSpeechSynthesisSupported =
48+
"speechSynthesis" in window &&
49+
typeof SpeechSynthesisUtterance !== "undefined";
5250

51+
export const speak = (text: string, language: SpeakLanguageEnum) => {
5352
if (!isSpeechSynthesisSupported) {
54-
reportHandledErrorOnce(
55-
`Speech synthesis is not supported in this browser. Browser info: ${navigator.userAgent}`,
56-
);
5753
return;
5854
}
5955

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { CardUnderReviewStore } from "../../store/card-under-review-store.ts";
2+
import { isSpeechSynthesisSupported } from "../../lib/voice-playback/speak.ts";
3+
import { throttle } from "../../lib/throttle/throttle.ts";
4+
import { css, cx } from "@emotion/css";
5+
import { theme } from "../../ui/theme.tsx";
6+
import React from "react";
7+
import { observer } from "mobx-react-lite";
8+
9+
type Props = {
10+
card: CardUnderReviewStore;
11+
type: "front" | "back";
12+
};
13+
14+
export const CardSpeaker = observer((props: Props) => {
15+
const { card, type } = props;
16+
if (
17+
!isSpeechSynthesisSupported ||
18+
!card.isOpened ||
19+
type !== card.deckSpeakField ||
20+
!card.isSpeakingCardsEnabledSettings
21+
) {
22+
return null;
23+
}
24+
25+
// throttle is needed to avoid user clicking on the speaker button many times in a row hence creating many sounds
26+
return (
27+
<i
28+
onClick={throttle(card.speak, 500)}
29+
className={cx(
30+
"mdi mdi-play-circle mdi-24px",
31+
css({
32+
cursor: "pointer",
33+
position: "relative",
34+
top: 3,
35+
color: theme.buttonColor,
36+
}),
37+
)}
38+
/>
39+
);
40+
});

src/screens/deck-review/card.tsx

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { theme } from "../../ui/theme.tsx";
55
import { observer } from "mobx-react-lite";
66
import { CardUnderReviewStore } from "../../store/card-under-review-store.ts";
77
import { HorizontalDivider } from "../../ui/horizontal-divider.tsx";
8+
import { CardSpeaker } from "./card-speaker.tsx";
89

910
export const cardSize = 310;
1011

@@ -42,9 +43,15 @@ export const Card = observer(({ card, style, animate }: Props) => {
4243
color: theme.textColor,
4344
})}
4445
>
45-
<div>{card.front}</div>
46+
<div>
47+
{card.front} <CardSpeaker card={card} type={"front"} />
48+
</div>
4649
{card.isOpened ? <HorizontalDivider /> : null}
47-
{card.isOpened ? <div>{card.back}</div> : null}
50+
{card.isOpened ? (
51+
<div>
52+
{card.back} <CardSpeaker card={card} type={"back"} />
53+
</div>
54+
) : null}
4855
{card.isOpened && card.example ? (
4956
<div
5057
className={css({ fontWeight: 400, fontSize: 14, paddingTop: 8 })}

src/store/card-under-review-store.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,11 @@ export class CardUnderReviewStore {
2525
isOpened = false;
2626
state?: CardState;
2727

28-
constructor(card: DeckCardDbType, deck: DeckWithCardsWithReviewType) {
28+
constructor(
29+
card: DeckCardDbType,
30+
deck: DeckWithCardsWithReviewType,
31+
public isSpeakingCardsEnabledSettings: boolean,
32+
) {
2933
this.id = card.id;
3034
this.front = card.front;
3135
this.back = card.back;
@@ -51,7 +55,7 @@ export class CardUnderReviewStore {
5155
}
5256

5357
speak() {
54-
if (!this.deckSpeakLocale || !this.deckSpeakField) {
58+
if (!this.isSpeakingCardsEnabledSettings || !this.deckSpeakLocale || !this.deckSpeakField) {
5559
return;
5660
}
5761
if (!isEnumValid(this.deckSpeakLocale, SpeakLanguageEnum)) {

src/store/deck-form-store.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -337,7 +337,7 @@ export class DeckFormStore {
337337
})
338338
.then((response) => {
339339
this.form = createUpdateForm(response.id, response);
340-
deckListStore.replaceDeck(response)
340+
deckListStore.replaceDeck(response);
341341
})
342342
.finally(
343343
action(() => {

src/store/review-store.ts

Lines changed: 5 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -24,35 +24,32 @@ export class ReviewStore {
2424
initialCardCount?: number;
2525

2626
isReviewSending = false;
27-
isSpeakingCards = false;
2827

2928
constructor() {
3029
makeAutoObservable(this, {}, { autoBind: true });
3130
}
3231

3332
startDeckReview(
3433
deck: DeckWithCardsWithReviewType,
35-
isSpeakingCards?: boolean,
34+
isSpeakingCardsEnabledSettings?: boolean,
3635
) {
3736
if (!deck.cardsToReview.length) {
3837
return;
3938
}
4039
deck.cardsToReview.forEach((card) => {
41-
this.cardsToReview.push(new CardUnderReviewStore(card, deck));
40+
this.cardsToReview.push(new CardUnderReviewStore(card, deck, !!isSpeakingCardsEnabledSettings));
4241
});
4342

4443
this.initialCardCount = this.cardsToReview.length;
4544
this.currentCardId = this.cardsToReview[0].id;
4645
if (this.cardsToReview.length > 1) {
4746
this.nextCardId = this.cardsToReview[1].id;
4847
}
49-
50-
this.isSpeakingCards = !!isSpeakingCards;
5148
}
5249

5350
startAllRepeatReview(
5451
myDecks: DeckWithCardsWithReviewType[],
55-
isSpeakingCards?: boolean,
52+
isSpeakingCardsEnabledSettings?: boolean,
5653
) {
5754
if (!myDecks.length) {
5855
return;
@@ -62,7 +59,7 @@ export class ReviewStore {
6259
deck.cardsToReview
6360
.filter((card) => card.type === "repeat")
6461
.forEach((card) => {
65-
this.cardsToReview.push(new CardUnderReviewStore(card, deck));
62+
this.cardsToReview.push(new CardUnderReviewStore(card, deck, !!isSpeakingCardsEnabledSettings));
6663
});
6764
});
6865

@@ -75,8 +72,6 @@ export class ReviewStore {
7572
if (this.cardsToReview.length > 1) {
7673
this.nextCardId = this.cardsToReview[1].id;
7774
}
78-
79-
this.isSpeakingCards = !!isSpeakingCards;
8075
}
8176

8277
get currentCard() {
@@ -103,9 +98,7 @@ export class ReviewStore {
10398
const currentCard = this.currentCard;
10499
assert(currentCard, "Current card should not be empty");
105100
currentCard.open();
106-
if (this.isSpeakingCards) {
107-
currentCard.speak();
108-
}
101+
currentCard.speak();
109102
}
110103

111104
changeState(cardState: CardState) {

0 commit comments

Comments
 (0)