sinProject-Inc/talk

View on GitHub
src/routes/(authed)/translate/+page.svelte

Summary

Maintainability
Test Coverage
<script lang="ts">
    import { browser } from '$app/environment'
    import { App } from '$lib/app/app'
    import ConfirmDeleteModal from '$lib/components/confirm_delete_modal.svelte'
    import IconButton from '$lib/components/icon_button.svelte'
    import SwapIcon from '$lib/components/icons/swap_icon.svelte'
    import Navbar from '$lib/components/navbar.svelte'
    import Snackbar from '$lib/components/snackbar.svelte'
    import SocialMetaTags from '$lib/components/social_meta_tags.svelte'
    import TextListText from '$lib/components/text_list_text.svelte'
    import TranslateBox from '$lib/components/translate/translate_box.svelte'
    import VersionFooter from '$lib/components/version_footer.svelte'
    import { TextError } from '$lib/general/text_error'
    import { DefaultLocales } from '$lib/locale/default_locales'
    import { LocaleCode } from '$lib/locale/locale_code'
    import { SpeechText } from '$lib/speech/speech_text'
    import { SpeechTextAreaElement } from '$lib/speech/speech_text_area_element'
    import { SubmissionText } from '$lib/speech/submission_text'
    import { TextToSpeechUrl } from '$lib/speech/text_to_speech_url'
    import { WebSpeechRecognition } from '$lib/speech/web_speech_recognition'
    import { DeleteTextApi } from '$lib/text/delete_text_api'
    import { TextsApi } from '$lib/text/texts_api'
    import { GetTranslationApi } from '$lib/translation/get_translation_api'
    import { Direction } from '$lib/view/direction'
    import { LocaleSelectElement } from '$lib/view/locale_select_element'
    import { WebLogger } from '$lib/view/log/web_logger'
    import { Message } from '$lib/view/message'
    import type { Locale, Text } from '@prisma/client'
    import { onMount } from 'svelte'
    import { _, locale, waitLocale } from 'svelte-i18n'
    import type { PageData } from './$types'

    export let data: PageData

    let source_locale_code = LocaleCode.japanese_japan
    let source_listening = false
    let source_locale_select_element: HTMLSelectElement
    let source_translate_box: TranslateBox

    let destination_locale_code = LocaleCode.english_united_states
    let destination_listening = false
    let destination_locale_select_element: HTMLSelectElement
    let destination_translate_box: TranslateBox

    let audio_element: HTMLAudioElement
    let web_speech_recognition: WebSpeechRecognition | undefined
    let history_texts: Text[] = []
    let copied_snackbar_timeout: number | undefined
    let copied_snackbar_visible = false

    let confirming_delete_text: Text | undefined

    const web_logger = new WebLogger('translate')

    $: listening = destination_listening || source_listening
    $: history_visible = history_texts.length > 0 ? 'visible' : 'invisible'

    function init_locale_select(): void {
        const locales = JSON.parse(data.locales) as Locale[]

        new LocaleSelectElement(destination_locale_select_element, locales).append_options_short()
        new LocaleSelectElement(source_locale_select_element, locales).append_options_short()
    }

    async function set_app_locale(): Promise<void> {
        $locale = destination_locale_code.code
        await waitLocale($locale)
    }

    async function fetch_history(): Promise<void> {
        history_texts = await new TextsApi(source_locale_code).fetch()
    }

    function set_locale(): void {
        destination_locale_code = new LocaleCode(destination_locale_select_element.value)
        source_locale_code = new LocaleCode(source_locale_select_element.value)

        set_app_locale()
        fetch_history()
    }

    async function select_default_locales(): Promise<void> {
        const default_locales = new DefaultLocales(
            destination_locale_select_element,
            source_locale_select_element
        )

        default_locales.load_from_storage()
        set_locale()
    }

    function store_locale(): void {
        localStorage.setItem('from_locale', destination_locale_code.code)
        localStorage.setItem('to_locale', source_locale_code.code)
    }

    function switch_textarea_body(): void {
        const from_value = destination_translate_box.get_value()
        const to_value = source_translate_box.get_value()

        source_translate_box.set_value(from_value)
        destination_translate_box.set_value(to_value)
    }

    function switch_locales(): void {
        destination_locale_select_element.value = source_locale_code.code
        source_locale_select_element.value = destination_locale_code.code

        web_logger.info(
            `switch_locales: source: ${source_locale_select_element.value}, destination: ${destination_locale_select_element.value}`
        )

        switch_textarea_body()
        set_locale()
        store_locale()
    }

    function on_change_locale_select(target_select_element: HTMLSelectElement): void {
        web_logger.info(
            `on_change_locale_select: source: ${source_locale_select_element.value}, destination: ${destination_locale_select_element.value}`
        )

        if (destination_locale_select_element.value === source_locale_select_element.value) {
            switch_locales()

            return
        }

        target_select_element === destination_locale_select_element
            ? destination_translate_box.clear()
            : source_translate_box.clear()

        set_locale()
        store_locale()
    }

    async function on_click_history_text(text: Text): Promise<void> {
        web_logger.info(
            `on_click_history_text: ${text.text}, locale: ${source_locale_select_element.value}`
        )

        source_translate_box.set_text(text)

        const submission_text = new SubmissionText(text.text)

        const translation_texts = await show_translation(
            submission_text,
            source_locale_code,
            destination_locale_code,
            destination_translate_box
        )

        speak_by_text(translation_texts, destination_locale_code)
    }

    async function delete_text(text?: Text): Promise<void> {
        if (!text) return

        await new DeleteTextApi(text).fetch()
        await fetch_history()
    }

    function set_translate_box_value(translate_box: TranslateBox, texts: Text[]): void {
        const translations = texts.map((translation_text) => translation_text.text)

        translate_box.set_value(translations.join('\n'))
    }

    async function show_translation(
        submission_text: SubmissionText,
        source_locale_code: LocaleCode,
        target_locale_code: LocaleCode,
        target_translate_box: TranslateBox
    ): Promise<Text[]> {
        const speech_text = new SpeechText(submission_text.text)
        const translated_texts = await new GetTranslationApi(
            speech_text,
            source_locale_code,
            target_locale_code
        ).fetch()

        set_translate_box_value(target_translate_box, translated_texts)

        return translated_texts
    }

    let text_to_speech_url = ''

    async function speak(value: string, locale_code: LocaleCode): Promise<void> {
        if (!value) return

        const new_text_to_speech_url = new TextToSpeechUrl(value, locale_code).url

        if (text_to_speech_url === new_text_to_speech_url) {
            audio_element.currentTime = 0

            try {
                await audio_element.play()
            } catch (error) {
                // eslint-disable-next-line no-console
                console.warn(error)
            }
        } else {
            text_to_speech_url = new_text_to_speech_url
        }
    }

    function speak_by_translate_box(translate_box: TranslateBox): void {
        const value = translate_box.get_value()
        const locale_code =
            translate_box === destination_translate_box ? destination_locale_code : source_locale_code

        web_logger.info(`speak: ${translate_box.get_value()}, locale: ${locale_code.code}`)

        speak(value, locale_code)
    }

    function speak_by_text(texts: Text[], locale_code: LocaleCode): void {
        if (texts.length === 0 || texts[0] === undefined) return

        speak(texts[0].text, locale_code)
    }

    async function translate(translate_box: TranslateBox): Promise<void> {
        const value = translate_box.get_value()

        if (!value) return

        const this_locale_code =
            translate_box === source_translate_box ? source_locale_code : destination_locale_code
        const partner_locale_code =
            translate_box === destination_translate_box ? source_locale_code : destination_locale_code
        const partner_translate_box =
            translate_box === destination_translate_box ? source_translate_box : destination_translate_box

        web_logger.info(
            `translate: ${translate_box.get_value()}, from: ${this_locale_code.code}, to: ${
                partner_locale_code.code
            }`
        )

        try {
            const submission_text = new SubmissionText(value)
            const translation_texts = await show_translation(
                submission_text,
                this_locale_code,
                partner_locale_code,
                partner_translate_box
            )

            await fetch_history()
            speak_by_text(translation_texts, partner_locale_code)
        } catch (err) {
            if (err instanceof TextError) {
                const message = $_(err.message_id)

                partner_translate_box.set_value(message)
            } else {
                // eslint-disable-next-line no-console
                console.error(err)
            }
        }
    }

    function get_locale_select_element(translate_box: TranslateBox): HTMLSelectElement {
        return translate_box === destination_translate_box
            ? destination_locale_select_element
            : source_locale_select_element
    }

    async function finish_listening(translate_box: TranslateBox): Promise<void> {
        const textarea_element = translate_box.get_textarea_element()

        textarea_element.placeholder = ''

        if (!textarea_element.value) return

        translate(translate_box)
    }

    function on_end_listening(translate_box: TranslateBox): void {
        if (translate_box === source_translate_box) {
            source_listening = false
        } else {
            destination_listening = false
        }

        finish_listening(translate_box)
    }

    function start_listening(translate_box: TranslateBox): void {
        if (audio_element.played) audio_element.pause()

        translate_box.clear()

        const locale_select_element = get_locale_select_element(translate_box)
        const locale_code = new LocaleCode(locale_select_element.value)
        const hint_message = new Message($_('recognizing'))
        const textarea_element = translate_box.get_textarea_element()
        const speech_text_area_element = new SpeechTextAreaElement(textarea_element, hint_message)

        web_logger.info(`start_listening: locale: ${locale_code.code}`)

        web_speech_recognition = new WebSpeechRecognition(locale_code, speech_text_area_element, () =>
            on_end_listening(translate_box)
        )

        web_speech_recognition.start_continuous()
    }

    function stop_listening(translate_box: TranslateBox): void {
        if (!web_speech_recognition) return

        web_speech_recognition.stop()

        finish_listening(translate_box)
    }

    async function on_copy(): Promise<void> {
        const text = await navigator.clipboard.readText()

        web_logger.info('on_copy: ' + text)

        if (copied_snackbar_timeout) clearTimeout(copied_snackbar_timeout)

        copied_snackbar_visible = true

        copied_snackbar_timeout = window.setTimeout(() => {
            copied_snackbar_visible = false
        }, 2000)
    }

    onMount(async () => {
        if (!browser) return

        web_logger.add_event_listeners()
        init_locale_select()
        await select_default_locales()
        source_translate_box.focus()
    })

    /* eslint-disable @typescript-eslint/explicit-function-return-type */
</script>

<svelte:head>
    <style>
        option {
            background-color: white !important;
        }
    </style>
</svelte:head>

<SocialMetaTags title={App.get_page_title('Translate')} description={App.description} />

<Navbar />

<div class="center-container h-[calc(100vh-65px)] w-screen">
    <div class="top-bar glass-panel my-3 flex h-10 items-center justify-evenly">
        <select
            class="w-full bg-transparent p-2 text-center outline-0 {listening
                ? ''
                : 'hover:glass-bump-bg-shine'} appearance-none text-ellipsis transition-all duration-300"
            name="language_1"
            disabled={listening}
            id="language_1"
            bind:this={source_locale_select_element}
            on:change={() => on_change_locale_select(source_locale_select_element)}
        />
        <div class="language-switcher">
            <IconButton
                enabled={!listening}
                on:click={() => {
                    if (!listening) switch_locales()
                }}><SwapIcon /></IconButton
            >
        </div>
        <select
            class="w-full bg-transparent p-2 text-center outline-0 {listening
                ? ''
                : 'hover:glass-bump-bg-shine'} appearance-none text-ellipsis transition-all duration-300"
            name="language_2"
            disabled={listening}
            id="language_2"
            bind:this={destination_locale_select_element}
            on:change={() => on_change_locale_select(destination_locale_select_element)}
        />
    </div>
    <div class="grid h-[calc(100vh-129px)] grid-rows-3 gap-y-3">
        <div class="grid h-[calc(100vh-129px)] grid-rows-3 gap-y-3">
            <TranslateBox
                bind:this={source_translate_box}
                on:keydown_enter={() => translate(source_translate_box)}
                on:start_listening={() => start_listening(source_translate_box)}
                on:stop_listening={() => stop_listening(source_translate_box)}
                on:speak={() => speak_by_translate_box(source_translate_box)}
                on:copy={on_copy}
                locale_code={source_locale_code}
                partner_listening={destination_listening}
                bind:listening={source_listening}
            />
            <TranslateBox
                bind:this={destination_translate_box}
                on:keydown_enter={() => translate(destination_translate_box)}
                on:start_listening={() => start_listening(destination_translate_box)}
                on:stop_listening={() => stop_listening(destination_translate_box)}
                on:speak={() => speak_by_translate_box(destination_translate_box)}
                on:copy={on_copy}
                locale_code={destination_locale_code}
                partner_listening={source_listening}
                bind:listening={destination_listening}
            />
            <div
                class="main-box glass-panel flex grow flex-col {history_visible}"
                data-testid="history-box"
            >
                <div class="title px-5 py-2">{$_('history')}</div>
                <div class="overflow-auto" lang={source_locale_code.code}>
                    {#each history_texts as text, i}
                        <TextListText
                            texts={history_texts}
                            {text}
                            {i}
                            deletable
                            on:click={() => on_click_history_text(text)}
                            delete_text={() => (confirming_delete_text = text)}
                            text_direction={new Direction(source_locale_code.code).value}
                        />
                    {/each}
                </div>
            </div>

            <VersionFooter />
        </div>
    </div>
</div>

<Snackbar text={$_('copied')} visible={copied_snackbar_visible} />

<audio class="mt-2 hidden" controls bind:this={audio_element} src={text_to_speech_url} autoplay />
{#if confirming_delete_text}
    <ConfirmDeleteModal
        on:close={() => {
            confirming_delete_text = undefined
        }}
        on:confirm_delete={() => {
            delete_text(confirming_delete_text)
        }}
    />
{/if}