268 lines
7.6 KiB
TypeScript
268 lines
7.6 KiB
TypeScript
import { useEffect, useState } from "react";
|
|
|
|
import { Button } from "#/components/ui/button";
|
|
import {
|
|
Item,
|
|
ItemContent,
|
|
ItemDescription,
|
|
ItemGroup,
|
|
ItemHeader,
|
|
ItemTitle,
|
|
} from "#/components/ui/item";
|
|
import { Label } from "#/components/ui/label";
|
|
import {
|
|
Progress,
|
|
ProgressLabel,
|
|
ProgressValue,
|
|
} from "#/components/ui/progress";
|
|
import { Section, SectionTitle } from "#/components/ui/section";
|
|
import { Slider } from "#/components/ui/slider";
|
|
import { Spinner } from "#/components/ui/spinner";
|
|
import { Switch } from "#/components/ui/switch";
|
|
import { useParty } from "#/hooks/use-party";
|
|
import { useSpotifyPlayer } from "#/hooks/use-spotify-player";
|
|
import { useUser } from "#/hooks/user";
|
|
import { client } from "#/lib/eden";
|
|
|
|
type PartyQuestion = NonNullable<
|
|
NonNullable<ReturnType<typeof useParty>["party"]>["data"]["currentQuestion"]
|
|
>;
|
|
|
|
function formatTimeLeft(milliseconds: number) {
|
|
const clamped = Math.max(0, milliseconds);
|
|
const totalSeconds = Math.ceil(clamped / 1000);
|
|
const minutes = Math.floor(totalSeconds / 60)
|
|
.toString()
|
|
.padStart(2, "0");
|
|
const seconds = (totalSeconds % 60).toString().padStart(2, "0");
|
|
return `${minutes}:${seconds}`;
|
|
}
|
|
|
|
function getQuestionAnnouncement(question: PartyQuestion) {
|
|
if (question.type === "numeric") {
|
|
return `${question.text}. Choose a number from ${question.range.min} to ${question.range.max}.`;
|
|
}
|
|
|
|
const options = question.options
|
|
.map((option, index) => `Option ${index + 1}: ${option}`)
|
|
.join(". ");
|
|
return `${question.text}. ${options}.`;
|
|
}
|
|
|
|
export function Question() {
|
|
const { party, members } = useParty();
|
|
const { user } = useUser();
|
|
const [now, setNow] = useState(() => Date.now());
|
|
const [selected, setSelected] = useState<number | null>(null);
|
|
const [selectedValue, setSelectedValue] = useState<number | null>(null);
|
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
const question = party?.data?.currentQuestion;
|
|
const spotifyTrackUri =
|
|
question?.song?.platform === "spotify" && question.song.platform_id
|
|
? `spotify:track:${question.song.platform_id}`
|
|
: null;
|
|
const { enabled: spotifyEnabled, setEnabled: setSpotifyEnabled } =
|
|
useSpotifyPlayer(spotifyTrackUri);
|
|
const questionStartTimestamp = question?.startTimestamp ?? null;
|
|
const questionAnnouncement = question
|
|
? getQuestionAnnouncement(question)
|
|
: null;
|
|
|
|
useEffect(() => {
|
|
const timer = window.setInterval(() => {
|
|
setNow(Date.now());
|
|
}, 1000);
|
|
|
|
return () => window.clearInterval(timer);
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (!question) return;
|
|
setSelected(null);
|
|
setSelectedValue(question.type === "numeric" ? question.range.min : null);
|
|
}, [question]);
|
|
|
|
useEffect(() => {
|
|
if (
|
|
!questionAnnouncement ||
|
|
questionStartTimestamp == null ||
|
|
!("speechSynthesis" in window)
|
|
) {
|
|
return;
|
|
}
|
|
|
|
const utterance = new SpeechSynthesisUtterance(questionAnnouncement);
|
|
utterance.rate = 0.95;
|
|
utterance.pitch = 1;
|
|
|
|
window.speechSynthesis.cancel();
|
|
window.speechSynthesis.speak(utterance);
|
|
|
|
return () => {
|
|
window.speechSynthesis.cancel();
|
|
};
|
|
}, [questionAnnouncement, questionStartTimestamp]);
|
|
if (!question)
|
|
return (
|
|
<Section>
|
|
<SectionTitle>Preparing quiz...</SectionTitle>
|
|
</Section>
|
|
);
|
|
|
|
const partyId = party.id;
|
|
const timeLeft = formatTimeLeft(question.endTimestamp - now);
|
|
const answeredCount = Object.keys(party.data.answers).length;
|
|
const hasResponded = user ? party.data.answers[user.id] != null : false;
|
|
const currentSelection =
|
|
selected ?? (user ? (party.data.answers[user.id]?.selected ?? null) : null);
|
|
const currentNumericSelection =
|
|
selectedValue ??
|
|
(user ? (party.data.answers[user.id]?.selectedValue ?? null) : null);
|
|
const numericMin = question.type === "numeric" ? question.range.min : 0;
|
|
const numericMax = question.type === "numeric" ? question.range.max : 0;
|
|
const progressValue = Math.max(
|
|
0,
|
|
Math.min(
|
|
100,
|
|
((question.endTimestamp - now) /
|
|
(question.endTimestamp - question.startTimestamp)) *
|
|
100,
|
|
),
|
|
);
|
|
|
|
async function handleChoiceAnswer(optionIndex: number) {
|
|
if (!partyId || hasResponded || isSubmitting) return;
|
|
setSelected(optionIndex);
|
|
setIsSubmitting(true);
|
|
try {
|
|
await client.api.party({ partyId }).quiz.response.post({
|
|
selected: optionIndex,
|
|
});
|
|
} finally {
|
|
setIsSubmitting(false);
|
|
}
|
|
}
|
|
|
|
async function handleNumericAnswer() {
|
|
if (!partyId || hasResponded || isSubmitting) return;
|
|
const value = selectedValue;
|
|
if (value == null) return;
|
|
setIsSubmitting(true);
|
|
try {
|
|
await client.api.party({ partyId }).quiz.response.post({
|
|
selected: value,
|
|
});
|
|
} finally {
|
|
setIsSubmitting(false);
|
|
}
|
|
}
|
|
|
|
return (
|
|
<Section>
|
|
<SectionTitle>Question {party.data.questionIndex + 1}</SectionTitle>
|
|
<ItemGroup>
|
|
<Item variant="muted">
|
|
<ItemContent>
|
|
<ItemTitle>{question.text}</ItemTitle>
|
|
<ItemDescription>{question.points} points</ItemDescription>
|
|
</ItemContent>
|
|
</Item>
|
|
<Item variant="muted">
|
|
<ItemContent>
|
|
<ItemHeader>
|
|
<Progress value={progressValue}>
|
|
<ProgressLabel>
|
|
{hasResponded ? "You responded" : "Waiting on your response"}
|
|
</ProgressLabel>
|
|
<ProgressValue>{() => timeLeft}</ProgressValue>
|
|
</Progress>
|
|
</ItemHeader>
|
|
<ItemDescription>
|
|
{answeredCount} / {members.length} responses
|
|
</ItemDescription>
|
|
</ItemContent>
|
|
</Item>
|
|
<Item variant="muted">
|
|
<ItemContent>
|
|
<ItemHeader>
|
|
<Label htmlFor="spotify-playback" className="cursor-pointer">
|
|
Audio playback
|
|
</Label>
|
|
<Switch
|
|
id="spotify-playback"
|
|
checked={spotifyEnabled}
|
|
onCheckedChange={(checked) =>
|
|
void setSpotifyEnabled(checked === true)
|
|
}
|
|
/>
|
|
</ItemHeader>
|
|
<ItemDescription>
|
|
{question.hideSongTitle
|
|
? "Listen closely and guess the song."
|
|
: (question.song?.name ??
|
|
"This question has no associated song.")}
|
|
</ItemDescription>
|
|
</ItemContent>
|
|
</Item>
|
|
{question.type === "numeric" ? (
|
|
<Item variant="muted">
|
|
<ItemContent>
|
|
<div className="space-y-3">
|
|
<Slider
|
|
min={numericMin}
|
|
max={numericMax}
|
|
step={1}
|
|
value={
|
|
currentNumericSelection != null
|
|
? [currentNumericSelection]
|
|
: undefined
|
|
}
|
|
onValueChange={(value) =>
|
|
setSelectedValue(
|
|
typeof value === "number" ? value : (value[0] ?? null),
|
|
)
|
|
}
|
|
/>
|
|
<div className="text-sm text-muted-foreground">
|
|
Exact value:{" "}
|
|
<span className="font-medium text-foreground">
|
|
{currentNumericSelection ?? question.correct}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</ItemContent>
|
|
<Button
|
|
disabled={hasResponded || isSubmitting || selectedValue == null}
|
|
onClick={() => void handleNumericAnswer()}
|
|
>
|
|
{isSubmitting ? <Spinner /> : "Answer"}
|
|
</Button>
|
|
</Item>
|
|
) : (
|
|
question.options?.map((option, index) => {
|
|
const isSelected = currentSelection === index;
|
|
return (
|
|
<Item key={option} variant={isSelected ? "outline" : "default"}>
|
|
<ItemContent>
|
|
<ItemHeader>
|
|
<ItemTitle>
|
|
{index + 1}. {option}
|
|
</ItemTitle>
|
|
{isSelected && <ItemDescription>Selected</ItemDescription>}
|
|
</ItemHeader>
|
|
</ItemContent>
|
|
<Button
|
|
variant={isSelected ? "secondary" : "default"}
|
|
disabled={hasResponded || isSubmitting}
|
|
onClick={() => handleChoiceAnswer(index)}
|
|
>
|
|
{isSubmitting && isSelected ? <Spinner /> : "Answer"}
|
|
</Button>
|
|
</Item>
|
|
);
|
|
})
|
|
)}
|
|
</ItemGroup>
|
|
</Section>
|
|
);
|
|
}
|