Skip to content

Commit

Permalink
Replay speaking card (#23)
Browse files Browse the repository at this point in the history
* Avoid reporting about unsupported Speech synthesis API on old Android

* Allow to replay speaking card
  • Loading branch information
kubk authored Dec 11, 2023
1 parent 8a128a6 commit 710107f
Show file tree
Hide file tree
Showing 7 changed files with 67 additions and 27 deletions.
2 changes: 1 addition & 1 deletion src/lib/typescript/enum-values.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,5 +9,5 @@ export function enumValues<E extends EnumLike>(enumObject: E): E[keyof E][] {
export function enumEntries<E extends EnumLike>(
enumObject: E,
): [keyof E, E[keyof E]][] {
return Object.entries(enumObject) as any
return Object.entries(enumObject) as any;
}
14 changes: 5 additions & 9 deletions src/lib/voice-playback/speak.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
import { reportHandledErrorOnce } from "../rollbar/rollbar.tsx";

export enum SpeakLanguageEnum {
USEnglish = "en-US",
Italian = "it-IT",
Expand All @@ -16,6 +14,7 @@ export enum SpeakLanguageEnum {
Japanese = "ja-JP",
Romanian = "ro-RO",
Portuguese = "pt-PT",
BrazilianPortuguese = "pt-BR",
Thai = "th-TH",
Croatian = "hr-HR",
Slovak = "sk-SK",
Expand Down Expand Up @@ -45,15 +44,12 @@ export const languageKeyToHuman = (str: string): string => {
return str.replace(/([A-Z])/g, " $1").trim();
};

export const speak = (text: string, language: SpeakLanguageEnum) => {
const isSpeechSynthesisSupported =
"speechSynthesis" in window &&
typeof SpeechSynthesisUtterance !== "undefined";
export const isSpeechSynthesisSupported =
"speechSynthesis" in window &&
typeof SpeechSynthesisUtterance !== "undefined";

export const speak = (text: string, language: SpeakLanguageEnum) => {
if (!isSpeechSynthesisSupported) {
reportHandledErrorOnce(
`Speech synthesis is not supported in this browser. Browser info: ${navigator.userAgent}`,
);
return;
}

Expand Down
40 changes: 40 additions & 0 deletions src/screens/deck-review/card-speaker.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { CardUnderReviewStore } from "../../store/card-under-review-store.ts";
import { isSpeechSynthesisSupported } from "../../lib/voice-playback/speak.ts";
import { throttle } from "../../lib/throttle/throttle.ts";
import { css, cx } from "@emotion/css";
import { theme } from "../../ui/theme.tsx";
import React from "react";
import { observer } from "mobx-react-lite";

type Props = {
card: CardUnderReviewStore;
type: "front" | "back";
};

export const CardSpeaker = observer((props: Props) => {
const { card, type } = props;
if (
!isSpeechSynthesisSupported ||
!card.isOpened ||
type !== card.deckSpeakField ||
!card.isSpeakingCardsEnabledSettings
) {
return null;
}

// throttle is needed to avoid user clicking on the speaker button many times in a row hence creating many sounds
return (
<i
onClick={throttle(card.speak, 500)}
className={cx(
"mdi mdi-play-circle mdi-24px",
css({
cursor: "pointer",
position: "relative",
top: 3,
color: theme.buttonColor,
}),
)}
/>
);
});
11 changes: 9 additions & 2 deletions src/screens/deck-review/card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { theme } from "../../ui/theme.tsx";
import { observer } from "mobx-react-lite";
import { CardUnderReviewStore } from "../../store/card-under-review-store.ts";
import { HorizontalDivider } from "../../ui/horizontal-divider.tsx";
import { CardSpeaker } from "./card-speaker.tsx";

export const cardSize = 310;

Expand Down Expand Up @@ -42,9 +43,15 @@ export const Card = observer(({ card, style, animate }: Props) => {
color: theme.textColor,
})}
>
<div>{card.front}</div>
<div>
{card.front} <CardSpeaker card={card} type={"front"} />
</div>
{card.isOpened ? <HorizontalDivider /> : null}
{card.isOpened ? <div>{card.back}</div> : null}
{card.isOpened ? (
<div>
{card.back} <CardSpeaker card={card} type={"back"} />
</div>
) : null}
{card.isOpened && card.example ? (
<div
className={css({ fontWeight: 400, fontSize: 14, paddingTop: 8 })}
Expand Down
8 changes: 6 additions & 2 deletions src/store/card-under-review-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,11 @@ export class CardUnderReviewStore {
isOpened = false;
state?: CardState;

constructor(card: DeckCardDbType, deck: DeckWithCardsWithReviewType) {
constructor(
card: DeckCardDbType,
deck: DeckWithCardsWithReviewType,
public isSpeakingCardsEnabledSettings: boolean,
) {
this.id = card.id;
this.front = card.front;
this.back = card.back;
Expand All @@ -51,7 +55,7 @@ export class CardUnderReviewStore {
}

speak() {
if (!this.deckSpeakLocale || !this.deckSpeakField) {
if (!this.isSpeakingCardsEnabledSettings || !this.deckSpeakLocale || !this.deckSpeakField) {
return;
}
if (!isEnumValid(this.deckSpeakLocale, SpeakLanguageEnum)) {
Expand Down
2 changes: 1 addition & 1 deletion src/store/deck-form-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -337,7 +337,7 @@ export class DeckFormStore {
})
.then((response) => {
this.form = createUpdateForm(response.id, response);
deckListStore.replaceDeck(response)
deckListStore.replaceDeck(response);
})
.finally(
action(() => {
Expand Down
17 changes: 5 additions & 12 deletions src/store/review-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,35 +24,32 @@ export class ReviewStore {
initialCardCount?: number;

isReviewSending = false;
isSpeakingCards = false;

constructor() {
makeAutoObservable(this, {}, { autoBind: true });
}

startDeckReview(
deck: DeckWithCardsWithReviewType,
isSpeakingCards?: boolean,
isSpeakingCardsEnabledSettings?: boolean,
) {
if (!deck.cardsToReview.length) {
return;
}
deck.cardsToReview.forEach((card) => {
this.cardsToReview.push(new CardUnderReviewStore(card, deck));
this.cardsToReview.push(new CardUnderReviewStore(card, deck, !!isSpeakingCardsEnabledSettings));
});

this.initialCardCount = this.cardsToReview.length;
this.currentCardId = this.cardsToReview[0].id;
if (this.cardsToReview.length > 1) {
this.nextCardId = this.cardsToReview[1].id;
}

this.isSpeakingCards = !!isSpeakingCards;
}

startAllRepeatReview(
myDecks: DeckWithCardsWithReviewType[],
isSpeakingCards?: boolean,
isSpeakingCardsEnabledSettings?: boolean,
) {
if (!myDecks.length) {
return;
Expand All @@ -62,7 +59,7 @@ export class ReviewStore {
deck.cardsToReview
.filter((card) => card.type === "repeat")
.forEach((card) => {
this.cardsToReview.push(new CardUnderReviewStore(card, deck));
this.cardsToReview.push(new CardUnderReviewStore(card, deck, !!isSpeakingCardsEnabledSettings));
});
});

Expand All @@ -75,8 +72,6 @@ export class ReviewStore {
if (this.cardsToReview.length > 1) {
this.nextCardId = this.cardsToReview[1].id;
}

this.isSpeakingCards = !!isSpeakingCards;
}

get currentCard() {
Expand All @@ -103,9 +98,7 @@ export class ReviewStore {
const currentCard = this.currentCard;
assert(currentCard, "Current card should not be empty");
currentCard.open();
if (this.isSpeakingCards) {
currentCard.speak();
}
currentCard.speak();
}

changeState(cardState: CardState) {
Expand Down

0 comments on commit 710107f

Please sign in to comment.