Skip to content

Commit

Permalink
feat: Preview Audio (#372)
Browse files Browse the repository at this point in the history
* Added module for requesting audio from Watts.

* Added preview_audio endpoint.

* Added SSML tags that Watts expects.

* Added required filename.

* Added API fetch for audio previews.

* Updated labels when audio is playing.

* Fixed position of Review audio buttons.

* Improved styling on review audio button.

* Added a reviewed state.

* Added an error toast when audio preview can't play.

* Cleaned up ReviewAudioButton.

* Reset audioReviewed when user edits either text box.

* fetch_env instead of get_env.

* Added doc and typespec.

* Added function to help build post requests.

* Added function to help build post requests.

* Changed audio to stop playing when user changes text.

* Added an enum to better track audio states.

* Added a text limit to textboxes.

* Renamed enum values.

* Added a better default for Watts ENV.

* Noted VPN requirement.
  • Loading branch information
cmaddox5 authored Jul 1, 2024
1 parent 55ca341 commit 831cda5
Show file tree
Hide file tree
Showing 9 changed files with 329 additions and 50 deletions.
7 changes: 7 additions & 0 deletions .envrc.template
Original file line number Diff line number Diff line change
Expand Up @@ -26,3 +26,10 @@ export DATABASE_HOSTNAME="localhost"

# Access key for allowing PA Message API calls. Required for endpoints in the /api/pa-messages scope. Generate a unique random string for local use.
export SCREENPLAY_API_KEY="local_api_key"

# Host and access key needed to communicate with Watts. Required when previewing PA message audio.
# For local use, set to the same value as your local instance of Watts.
# VPN is required for requests to watts-staging.
#export WATTS_URL="https://watts-staging.mbtace.com"
# API Key is in the Screens 1Password vault under `Watts Staging API Key`
#export WATTS_API_KEY=""
87 changes: 77 additions & 10 deletions assets/css/dashboard/new-pa-message-page/new-pa-message-page.scss
Original file line number Diff line number Diff line change
Expand Up @@ -147,13 +147,17 @@
}

.message-card {
.copy-text-button {
background-color: $bg-border-disabled;
width: 38px;
height: 38px;
.copy-button-col {
margin-top: 141px;

.copy-text-button {
background-color: $bg-border-disabled;
width: 38px;
height: 38px;

.bi-arrow-right-short {
fill: $text-disabled;
.bi-arrow-right-short {
fill: $text-disabled;
}
}
}

Expand All @@ -162,15 +166,78 @@
resize: none;
}

.audio-reviewed-text {
color: $text-primary;
margin-top: auto;
margin-bottom: auto;
align-self: center;

span {
vertical-align: middle;
}
}

.review-audio-button {
color: $button-primary;
margin-top: auto;
margin-bottom: auto;
margin-left: 16px;
padding: 0;

.bi {
margin-right: 8px;
}

&--audio-playing {
color: $text-primary;
text-decoration: none;
}

&:disabled {
color: $text-secondary;
text-decoration: none;
}
}

.review-audio-card {
height: 250px;
background-color: $cool-gray-15;
}
}

.error-alert-container {
display: flex;
justify-content: center;
align-items: center;
position: fixed;
left: 50%;
transform: translateX(-50%);
bottom: 40px;
z-index: 1001;
}

.review-audio-button {
color: $button-primary;
margin-top: auto;
margin-bottom: auto;
.error-alert {
width: 400px;
background-color: #ffdd00;
font-size: 16px;
display: flex;
align-items: center;

.btn-close {
&:hover {
background-color: transparent;
}
}

&__icon {
height: 24px;
width: 24px;
opacity: 60%;
flex-shrink: 0;
}

&__text {
padding-left: 8px;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,24 @@ interface Props {
disabled?: boolean;
label: string;
id: string;
maxLength: number;
}

const MessageTextBox = ({ text, onChangeText, disabled, label, id }: Props) => {
const MessageTextBox = ({
text,
onChangeText,
disabled,
label,
id,
maxLength,
}: Props) => {
return (
<Form.Group>
<Form.Label htmlFor={id}>{label}</Form.Label>
<Form.Control
id={id}
className="text-input"
maxLength={maxLength}
as="textarea"
value={text}
onChange={(textbox) => onChangeText(textbox.target.value)}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,14 @@
/* eslint-disable jsx-a11y/media-has-caption */
import React, { useState } from "react";
import { Button, Card, Col, Container, Form, Row } from "react-bootstrap";
import {
Alert,
Button,
Card,
Col,
Container,
Form,
Row,
} from "react-bootstrap";
import DaysPicker from "./DaysPicker";
import PriorityPicker from "./PriorityPicker";
import IntervalPicker from "./IntervalPicker";
Expand All @@ -8,7 +17,23 @@ import { useNavigate } from "react-router-dom";
import moment from "moment";
import DatePicker from "../../DatePicker";
import TimePicker from "../../TimePicker";
import { ArrowRightShort, PlusLg, VolumeUpFill } from "react-bootstrap-icons";
import {
ArrowRightShort,
CheckCircleFill,
ExclamationTriangleFill,
PlusLg,
VolumeUpFill,
} from "react-bootstrap-icons";
import cx from "classnames";

const MAX_TEXT_LENGTH = 2000;

enum AudioPreview {
Unreviewed,
Playing,
Reviewed,
Outdated,
}

const NewPaMessagePage = () => {
const now = moment();
Expand All @@ -22,9 +47,35 @@ const NewPaMessagePage = () => {
const [interval, setInterval] = useState("4");
const [visualText, setVisualText] = useState("");
const [phoneticText, setPhoneticText] = useState("");
const [audioState, setAudioState] = useState<AudioPreview>(
AudioPreview.Unreviewed,
);
const [errorMessage, setErrorMessage] = useState("");

const navigate = useNavigate();

const previewAudio = () => {
if (audioState === AudioPreview.Playing) return;

if (phoneticText.length === 0) {
setPhoneticText(visualText);
}

setAudioState(AudioPreview.Playing);
};

// Called after the audio preview plays in full
const onAudioEnded = () => {
setAudioState(AudioPreview.Reviewed);
};

const onAudioError = () => {
setAudioState(AudioPreview.Outdated);
setErrorMessage(
"Error occurred while fetching audio preview. Please try again.",
);
};

return (
<div className="new-pa-message-page">
<Form
Expand Down Expand Up @@ -115,41 +166,76 @@ const NewPaMessagePage = () => {
</Card>
<Card className="message-card">
<div className="title">Message</div>
<Row className="align-items-center">
<Row>
<Col>
<MessageTextBox
id="visual-text-box"
text={visualText}
onChangeText={setVisualText}
onChangeText={(text) => {
setVisualText(text);
if (audioState !== AudioPreview.Unreviewed) {
setAudioState(AudioPreview.Outdated);
}
}}
label="Text"
maxLength={MAX_TEXT_LENGTH}
/>
</Col>
<Col md="auto">
<Col md="auto" className="copy-button-col">
<Button
disabled={visualText.length === 0}
className="copy-text-button"
onClick={() => setPhoneticText(visualText)}
onClick={() => {
setPhoneticText(visualText);
if (audioState !== AudioPreview.Unreviewed) {
setAudioState(AudioPreview.Outdated);
}
}}
aria-label="copy-visual-to-phonetic"
>
<ArrowRightShort />
</Button>
</Col>
<Col>
{phoneticText.length > 0 ? (
<MessageTextBox
id="phonetic-audio-text-box"
text={phoneticText}
onChangeText={setPhoneticText}
disabled={phoneticText.length === 0}
label="Phonetic Audio"
/>
<>
<MessageTextBox
id="phonetic-audio-text-box"
text={phoneticText}
onChangeText={(text) => {
setPhoneticText(text);
if (audioState !== AudioPreview.Unreviewed) {
setAudioState(AudioPreview.Outdated);
}
}}
disabled={phoneticText.length === 0}
label="Phonetic Audio"
maxLength={MAX_TEXT_LENGTH}
/>
<ReviewAudioButton
audioState={audioState}
onClick={previewAudio}
/>
</>
) : (
<Card className="review-audio-card">
<Button className="review-audio-button" variant="link">
<VolumeUpFill height={12} />
Review audio
</Button>
</Card>
<>
<div className="form-label">Phonetic Audio</div>
<Card className="review-audio-card">
<ReviewAudioButton
audioState={audioState}
disabled={visualText.length === 0}
onClick={previewAudio}
/>
</Card>
</>
)}
{audioState === AudioPreview.Playing && (
<audio
src={`/api/pa-messages/preview_audio?text=${phoneticText}`}
autoPlay
onEnded={onAudioEnded}
onError={onAudioError}
/>
)}
</Col>
</Row>
Expand All @@ -171,9 +257,61 @@ const NewPaMessagePage = () => {
</Button>
</Row>
</Container>
<div className="error-alert-container">
<Alert
show={errorMessage.length > 0}
variant="primary"
onClose={() => setErrorMessage("")}
dismissible
className="error-alert"
>
<ExclamationTriangleFill className="error-alert__icon" />
<div className="error-alert__text">{errorMessage}</div>
</Alert>
</div>
</Form>
</div>
);
};

interface ReviewAudioButtonProps {
audioState: AudioPreview;
disabled?: boolean;
onClick: () => void;
}

const ReviewAudioButton = ({
audioState,
disabled,
onClick,
}: ReviewAudioButtonProps) => {
const audioPlaying = audioState === AudioPreview.Playing;
return audioState === AudioPreview.Reviewed ? (
<div className="audio-reviewed-text">
<span>
<CheckCircleFill /> Audio reviewed
</span>
<Button className="review-audio-button" onClick={onClick} variant="link">
Replay
</Button>
</div>
) : (
<Button
disabled={!audioPlaying && disabled}
className={cx("review-audio-button", {
"review-audio-button--audio-playing": audioPlaying,
})}
variant="link"
onClick={onClick}
>
{audioState === AudioPreview.Outdated ? (
<ExclamationTriangleFill fill="#FFC107" height={16} />
) : (
<VolumeUpFill height={16} />
)}
{audioPlaying ? "Reviewing audio" : "Review audio"}
</Button>
);
};

export default NewPaMessagePage;
Loading

0 comments on commit 831cda5

Please sign in to comment.