From 6be3f740edc9cd8d59088c6c411d1813f4957a48 Mon Sep 17 00:00:00 2001 From: Egor Gorbachev <7gorbachevm@gmail.com> Date: Wed, 7 Feb 2024 20:20:42 +0700 Subject: [PATCH] Card voice from url (#14) * Card voice from URL --- .../component-catalog/card-preview-story.tsx | 1 + .../deck-form/store/deck-form-store.test.ts | 4 + .../deck-form/store/deck-form-store.ts | 4 + .../store/quick-add-card-form-store.ts | 1 + .../deck-review/store/card-preview-store.ts | 77 +++++++++++++------ .../store/card-under-review-store.ts | 46 +++++++++-- .../deck-review/store/review-store.test.ts | 8 ++ src/screens/shared/card/card-speaker.tsx | 9 +-- src/screens/shared/card/card.tsx | 1 + src/store/deck-list-store.test.ts | 9 +++ 10 files changed, 123 insertions(+), 37 deletions(-) diff --git a/src/screens/component-catalog/card-preview-story.tsx b/src/screens/component-catalog/card-preview-story.tsx index 5063726d..6b9734d0 100644 --- a/src/screens/component-catalog/card-preview-story.tsx +++ b/src/screens/component-catalog/card-preview-story.tsx @@ -19,6 +19,7 @@ const createCardPreviewForm = (card: { example: new TextField(card.example ?? ""), answerType: new TextField("remember"), answerFormType: "new", + options: null, answers: new ListField([]), answerId: "0", }, diff --git a/src/screens/deck-form/store/deck-form-store.test.ts b/src/screens/deck-form/store/deck-form-store.test.ts index 14ba0027..16078109 100644 --- a/src/screens/deck-form/store/deck-form-store.test.ts +++ b/src/screens/deck-form/store/deck-form-store.test.ts @@ -38,6 +38,7 @@ const mapUpsertDeckRequestToResponse = ( back: card.back, answer_type: "remember", answers: null, + options: null, }; }), }, @@ -75,6 +76,7 @@ vi.mock("./../../../store/deck-list-store.ts", () => { back: "Время", answer_type: "remember", answers: null, + options: null, }, { id: 4, @@ -85,6 +87,7 @@ vi.mock("./../../../store/deck-list-store.ts", () => { back: "Год", answer_type: "remember", answers: null, + options: null, }, { id: 5, @@ -95,6 +98,7 @@ vi.mock("./../../../store/deck-list-store.ts", () => { back: "Дорога", answer_type: "remember", answers: null, + options: null, }, ]; diff --git a/src/screens/deck-form/store/deck-form-store.ts b/src/screens/deck-form/store/deck-form-store.ts index d9fd9968..dce3e864 100644 --- a/src/screens/deck-form/store/deck-form-store.ts +++ b/src/screens/deck-form/store/deck-form-store.ts @@ -15,6 +15,7 @@ import { showAlert } from "../../../lib/telegram/show-alert.ts"; import { fuzzySearch } from "../../../lib/string/fuzzy-search.ts"; import { DeckCardDbType, + DeckCardOptionsDbType, DeckSpeakFieldEnum, DeckWithCardsDbType, } from "../../../../functions/db/deck/decks-with-cards-schema.ts"; @@ -45,6 +46,7 @@ export type CardFormType = { answerId?: string; answerFormType?: "new" | "edit"; id?: number; + options: DeckCardOptionsDbType; }; type DeckFormType = { @@ -122,6 +124,7 @@ const createUpdateForm = ( back: createCardSideField(card.back), example: new TextField(card.example || ""), answerType: createAnswerTypeField(card), + options: card.options, answers: createAnswerListField( card.answers ? card.answers.map((answer) => ({ @@ -334,6 +337,7 @@ export class DeckFormStore implements CardFormStoreInterface { back: createCardSideField(""), example: new TextField(""), answerType: createAnswerTypeField(), + options: null, answers: createAnswerListField([], () => this.cardForm), }); } diff --git a/src/screens/deck-form/store/quick-add-card-form-store.ts b/src/screens/deck-form/store/quick-add-card-form-store.ts index 73b539da..de42d927 100644 --- a/src/screens/deck-form/store/quick-add-card-form-store.ts +++ b/src/screens/deck-form/store/quick-add-card-form-store.ts @@ -27,6 +27,7 @@ export class QuickAddCardFormStore implements CardFormStoreInterface { front: createCardSideField(""), example: new TextField(""), answerType: createAnswerTypeField(), + options: null, answers: createAnswerListField([], () => this.cardForm), }; isSending = false; diff --git a/src/screens/deck-review/store/card-preview-store.ts b/src/screens/deck-review/store/card-preview-store.ts index e8a0de12..fb70d25b 100644 --- a/src/screens/deck-review/store/card-preview-store.ts +++ b/src/screens/deck-review/store/card-preview-store.ts @@ -7,7 +7,11 @@ import { CardAnswerType } from "../../../../functions/db/custom-types.ts"; import { makeAutoObservable } from "mobx"; import { userStore } from "../../../store/user-store.ts"; import { isEnumValid } from "../../../lib/typescript/is-enum-valid.ts"; -import { speak, SpeakLanguageEnum } from "../../../lib/voice-playback/speak.ts"; +import { + isSpeechSynthesisSupported, + speak, + SpeakLanguageEnum, +} from "../../../lib/voice-playback/speak.ts"; import { removeAllTags } from "../../../lib/sanitize-html/remove-all-tags.ts"; import { CardFormStoreInterface } from "../../deck-form/store/card-form-store-interface.ts"; import { assert } from "../../../lib/typescript/assert.ts"; @@ -22,6 +26,7 @@ export class CardPreviewStore implements LimitedCardUnderReviewStore { answers: CardAnswerDbType[] = []; answer?: CardAnswerDbType; + voice?: HTMLAudioElement; deckSpeakLocale: string | null = null; deckSpeakField: DeckSpeakFieldEnum | null = null; @@ -31,7 +36,14 @@ export class CardPreviewStore implements LimitedCardUnderReviewStore { isOverflowing = new BooleanToggle(false); constructor(cardFormStore: CardFormStoreInterface) { - makeAutoObservable(this, {}, { autoBind: true }); + makeAutoObservable( + this, + { + isCardSpeakerVisible: false, + }, + { autoBind: true }, + ); + const form = cardFormStore.cardForm; assert(form, "form is not defined"); this.id = 9999; @@ -52,28 +64,13 @@ export class CardPreviewStore implements LimitedCardUnderReviewStore { this.deckSpeakLocale = deckForm.speakingCardsLocale.value ?? null; this.deckSpeakField = deckForm.speakingCardsField.value ?? null; - } - - speak() { - if ( - !this.isSpeakingCardsEnabledSettings || - !this.deckSpeakLocale || - !this.deckSpeakField - ) { - return; - } - if (!isEnumValid(this.deckSpeakLocale, SpeakLanguageEnum)) { - return; + if (form.options?.voice) { + const audio = new Audio(form.options.voice); + // Preload audio to avoid slow delay when playing voice + audio.load(); + this.voice = audio; } - - const text = this[this.deckSpeakField]; - - speak(removeAllTags(text), this.deckSpeakLocale); - } - - get isSpeakingCardsEnabledSettings() { - return userStore.isSpeakingCardsEnabled; } openWithAnswer(answer: CardAnswerDbType) { @@ -93,4 +90,40 @@ export class CardPreviewStore implements LimitedCardUnderReviewStore { this.speak(); } } + + speak() { + if (!userStore.isSpeakingCardsEnabled) { + return; + } + + if (this.voice) { + this.voice.play(); + return; + } + + if (!this.deckSpeakLocale || !this.deckSpeakField) { + return; + } + + if (!isEnumValid(this.deckSpeakLocale, SpeakLanguageEnum)) { + return; + } + + const text = this[this.deckSpeakField]; + + speak(removeAllTags(text), this.deckSpeakLocale); + } + + isCardSpeakerVisible(type: DeckSpeakFieldEnum) { + if (this.voice) { + return type === "back"; + } + + return ( + isSpeechSynthesisSupported && + this.isOpened && + type === this.deckSpeakField && + userStore.isSpeakingCardsEnabled + ); + } } diff --git a/src/screens/deck-review/store/card-under-review-store.ts b/src/screens/deck-review/store/card-under-review-store.ts index e993d42e..3c7c0e37 100644 --- a/src/screens/deck-review/store/card-under-review-store.ts +++ b/src/screens/deck-review/store/card-under-review-store.ts @@ -5,7 +5,11 @@ import { DeckSpeakFieldEnum, } from "../../../../functions/db/deck/decks-with-cards-schema.ts"; import { DeckWithCardsWithReviewType } from "../../../store/deck-list-store.ts"; -import { speak, SpeakLanguageEnum } from "../../../lib/voice-playback/speak.ts"; +import { + isSpeechSynthesisSupported, + speak, + SpeakLanguageEnum, +} from "../../../lib/voice-playback/speak.ts"; import { isEnumValid } from "../../../lib/typescript/is-enum-valid.ts"; import { CardAnswerType } from "../../../../functions/db/custom-types.ts"; import { assert } from "../../../lib/typescript/assert.ts"; @@ -25,6 +29,7 @@ export class CardUnderReviewStore { back: string; example: string | null = null; deckName?: string; + voice?: HTMLAudioElement; deckSpeakLocale: string | null = null; deckSpeakField: DeckSpeakFieldEnum | null = null; answerType: CardAnswerType; @@ -51,8 +56,17 @@ export class CardUnderReviewStore { this.deckName = deck.name; this.deckSpeakLocale = deck.speak_locale; this.deckSpeakField = deck.speak_field; + if (card.options?.voice) { + const audio = new Audio(card.options.voice); + audio.load(); + this.voice = audio; + } - makeAutoObservable(this, {}, { autoBind: true }); + makeAutoObservable( + this, + { isCardSpeakerVisible: false }, + { autoBind: true }, + ); } open() { @@ -77,11 +91,16 @@ export class CardUnderReviewStore { } speak() { - if ( - !userStore.isSpeakingCardsEnabled || - !this.deckSpeakLocale || - !this.deckSpeakField - ) { + if (!userStore.isSpeakingCardsEnabled) { + return; + } + + if (this.voice) { + this.voice.play(); + return; + } + + if (!this.deckSpeakLocale || !this.deckSpeakField) { return; } @@ -93,4 +112,17 @@ export class CardUnderReviewStore { speak(removeAllTags(text), this.deckSpeakLocale); } + + isCardSpeakerVisible(type: "front" | "back") { + if (this.voice) { + return type === "back"; + } + + return ( + isSpeechSynthesisSupported && + this.isOpened && + type === this.deckSpeakField && + userStore.isSpeakingCardsEnabled + ); + } } diff --git a/src/screens/deck-review/store/review-store.test.ts b/src/screens/deck-review/store/review-store.test.ts index ed37b92e..ae7a280e 100644 --- a/src/screens/deck-review/store/review-store.test.ts +++ b/src/screens/deck-review/store/review-store.test.ts @@ -43,6 +43,7 @@ const repeatCardsMock: DeckCardDbTypeWithType[] = [ type: "repeat", answer_type: "remember", answers: null, + options: null, }, { id: 4, @@ -54,6 +55,7 @@ const repeatCardsMock: DeckCardDbTypeWithType[] = [ type: "repeat", answer_type: "remember", answers: null, + options: null, }, { id: 5, @@ -65,6 +67,7 @@ const repeatCardsMock: DeckCardDbTypeWithType[] = [ type: "repeat", answer_type: "remember", answers: null, + options: null, }, { id: 6, @@ -76,6 +79,7 @@ const repeatCardsMock: DeckCardDbTypeWithType[] = [ type: "repeat", answer_type: "remember", answers: null, + options: null, }, ]; @@ -90,6 +94,7 @@ const newCardsMock: DeckCardDbTypeWithType[] = [ type: "new", answer_type: "remember", answers: null, + options: null, }, { id: 4, @@ -101,6 +106,7 @@ const newCardsMock: DeckCardDbTypeWithType[] = [ type: "new", answer_type: "remember", answers: null, + options: null, }, { id: 5, @@ -112,6 +118,7 @@ const newCardsMock: DeckCardDbTypeWithType[] = [ type: "new", answer_type: "remember", answers: null, + options: null, }, { id: 6, @@ -123,6 +130,7 @@ const newCardsMock: DeckCardDbTypeWithType[] = [ type: "new", answer_type: "remember", answers: null, + options: null, }, ]; diff --git a/src/screens/shared/card/card-speaker.tsx b/src/screens/shared/card/card-speaker.tsx index 95ebcb26..849d8396 100644 --- a/src/screens/shared/card/card-speaker.tsx +++ b/src/screens/shared/card/card-speaker.tsx @@ -1,11 +1,9 @@ -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"; import { LimitedCardUnderReviewStore } from "./card.tsx"; -import { userStore } from "../../../store/user-store.ts"; type Props = { card: LimitedCardUnderReviewStore; @@ -14,12 +12,7 @@ type Props = { export const CardSpeaker = observer((props: Props) => { const { card, type } = props; - if ( - !isSpeechSynthesisSupported || - !card.isOpened || - type !== card.deckSpeakField || - !userStore.isSpeakingCardsEnabled - ) { + if (!card.isCardSpeakerVisible(type)) { return null; } diff --git a/src/screens/shared/card/card.tsx b/src/screens/shared/card/card.tsx index 79a16526..31f5c60e 100644 --- a/src/screens/shared/card/card.tsx +++ b/src/screens/shared/card/card.tsx @@ -25,6 +25,7 @@ export type LimitedCardUnderReviewStore = Pick< | "openWithAnswer" | "open" | "isOverflowing" + | "isCardSpeakerVisible" >; type Props = { diff --git a/src/store/deck-list-store.test.ts b/src/store/deck-list-store.test.ts index bd1339d9..51eca4b7 100644 --- a/src/store/deck-list-store.test.ts +++ b/src/store/deck-list-store.test.ts @@ -64,6 +64,7 @@ vi.mock("../api/api.ts", () => { answer_type: "remember", example: null, answers: null, + options: null, }, { id: 2, @@ -74,6 +75,7 @@ vi.mock("../api/api.ts", () => { answer_type: "remember", example: null, answers: null, + options: null, }, { id: 3, @@ -84,6 +86,7 @@ vi.mock("../api/api.ts", () => { answer_type: "remember", example: null, answers: null, + options: null, }, { id: 5, @@ -94,6 +97,7 @@ vi.mock("../api/api.ts", () => { answer_type: "remember", example: null, answers: null, + options: null, }, ], }, @@ -120,6 +124,7 @@ vi.mock("../api/api.ts", () => { answer_type: "remember", example: null, answers: null, + options: null, }, ], }, @@ -182,6 +187,7 @@ describe("deck list store", () => { "example": null, "front": "d1c5 - f", "id": 5, + "options": null, "type": "repeat", }, { @@ -193,6 +199,7 @@ describe("deck list store", () => { "example": null, "front": "d1c2 - f", "id": 2, + "options": null, "type": "repeat", }, { @@ -204,6 +211,7 @@ describe("deck list store", () => { "example": null, "front": "d1c1 - f", "id": 1, + "options": null, "type": "new", }, { @@ -215,6 +223,7 @@ describe("deck list store", () => { "example": null, "front": "d1c3 - f", "id": 3, + "options": null, "type": "new", }, ]