Added Audio Questions

This commit is contained in:
2025-09-11 20:17:07 +02:00
parent b98e25d4e7
commit 8ce77c1250
10 changed files with 442 additions and 8 deletions

View File

@@ -1,4 +1,8 @@
{
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true
"editor.formatOnSave": true,
"[svg]": {
"editor.defaultFormatter": "jock.svg"
},
"svg.preview.background": "dark-transparent"
}

View File

@@ -0,0 +1,78 @@
<script lang="ts">
import AudioPlayerComponent from "./AudioPlayerComponent.svelte";
import type { AudioMultipleChoiceQuestion } from "./games/games";
const path = "/sounds/";
interface Props {
question: AudioMultipleChoiceQuestion;
showAnswer: boolean;
showQuestion: boolean;
showPlayer: boolean;
[key: string]: unknown;
}
let { question, showAnswer, showQuestion, showPlayer }: Props = $props();
function shuffle<T>(array: T[]) {
let currentIndex = array.length;
// While there remain elements to shuffle...
while (currentIndex != 0) {
// Pick a remaining element...
let randomIndex = Math.floor(Math.random() * currentIndex);
currentIndex--;
// And swap it with the current element.
[array[currentIndex], array[randomIndex]] = [array[randomIndex], array[currentIndex]];
}
}
const answer = question.data.choices[0];
let _choices = [...question.data.choices];
shuffle(_choices);
</script>
<div class="mb-4 flex grow flex-col items-center text-6xl">
{#if showQuestion}
<div class="flex grow-1 items-center">
<div class="text-center">{question.data.question}</div>
</div>
{/if}
{#if showPlayer}
<div class="flex w-full flex-col justify-center">
<AudioPlayerComponent src={path + question.data.audio} />
</div>
{/if}
{#if showQuestion}
<div class="flex w-full grow-1 flex-wrap items-center justify-around gap-2">
{#each _choices as choice}
<div class="choiceCard {showAnswer && choice === answer ? 'answer' : ''}">
{choice}
</div>
{/each}
</div>
{/if}
</div>
<style>
.choiceCard {
border: 1px solid black;
border-radius: 5px;
display: flex;
align-items: center;
justify-content: center;
height: fit-content;
padding: 32px;
/* font-size: larger;
font-weight: bold;
cursor: pointer; */
}
.answer {
background-color: rgb(87, 255, 87);
box-shadow: 0 0 20px 5px rgb(87, 255, 87);
border: 1px solid rgb(50, 141, 50);
}
</style>

View File

@@ -0,0 +1,217 @@
<script lang="ts">
import { onMount } from "svelte";
interface Props {
src: string;
[key: string]: unknown;
}
const saveKey = "jeopardyAudioVolume";
let { src }: Props = $props();
let time = $state(0);
let duration = $state(0);
let paused = $state(true);
let volume = $state(100);
onMount(() => {
let savedVolume = localStorage.getItem(saveKey);
if (savedVolume != null) {
let parsedVolume = parseFloat(savedVolume);
if (!isNaN(parsedVolume)) {
setVolume(parsedVolume);
}
}
});
function setVolume(vol: number): void {
const audioPlayer = document.getElementById("AudioQuestionAudioPlayer") as HTMLMediaElement;
volume = Math.round(vol * 100);
if (audioPlayer) {
audioPlayer.volume = vol;
}
}
function format(time: number) {
if (isNaN(time)) return "...";
const minutes = Math.floor(time / 60);
const seconds = Math.floor(time % 60);
return `${minutes}:${seconds < 10 ? `0${seconds}` : seconds}`;
}
</script>
<div class={["player", { paused }]}>
<audio
id="AudioQuestionAudioPlayer"
{src}
bind:currentTime={time}
bind:duration
bind:paused
onended={() => {
time = 0;
}}
></audio>
<button class="play" aria-label={paused ? "play" : "pause"} onclick={() => (paused = !paused)}
></button>
<div class="info">
<div class="time">
<span>{format(time)}</span>
<div
class="slider"
onpointerdown={(e) => {
const div = e.currentTarget;
function seek(e: PointerEvent) {
const { left, width } = div.getBoundingClientRect();
let p = (e.clientX - left) / width;
if (p < 0) p = 0;
if (p > 1) p = 1;
time = p * duration;
}
seek(e);
window.addEventListener("pointermove", seek);
window.addEventListener(
"pointerup",
() => {
window.removeEventListener("pointermove", seek);
},
{
once: true
}
);
}}
>
<div class="progress" style="--progress: {time / duration}%"></div>
</div>
<span>{duration ? format(duration) : "--:--"}</span>
</div>
</div>
<div></div>
<div class="info">
<div class="time">
<div
class="slider"
onpointerdown={(e) => {
const div = e.currentTarget;
function seek(e: PointerEvent) {
const { left, width } = div.getBoundingClientRect();
let p = (e.clientX - left) / width;
if (p < 0) p = 0;
if (p > 1) p = 1;
setVolume(p);
localStorage.setItem(saveKey, p.toString());
}
seek(e);
window.addEventListener("pointermove", seek);
window.addEventListener(
"pointerup",
() => {
window.removeEventListener("pointermove", seek);
},
{
once: true
}
);
}}
>
<div class="volume" style="--volume: {volume / 100}%"></div>
</div>
<span>{volume}%</span>
</div>
</div>
</div>
<style>
.player {
display: grid;
grid-template-columns: 2.5em 1fr;
align-items: center;
gap: 1em;
padding: 0.5em 1em 0.5em 0.5em;
border-radius: 2em;
background: gray;
transition: filter 0.2s;
color: white;
user-select: none;
font-size: medium;
}
.player:not(.paused) {
color: white;
filter: drop-shadow(0.5em 0.5em 1em rgba(0, 0, 0, 0.1));
}
button {
width: 100%;
aspect-ratio: 1;
background-repeat: no-repeat;
background-position: 50% 50%;
border-radius: 50%;
cursor: pointer;
}
.play {
width: 40px;
}
[aria-label="pause"] {
background-image: url(/images/pause.svg);
}
[aria-label="play"] {
background-image: url(/images/play.svg);
}
.info {
overflow: hidden;
}
.time {
display: flex;
align-items: center;
gap: 0.5em;
}
.time span {
font-size: 0.7em;
}
.slider {
flex: 1;
height: 0.5em;
background: lightgray;
border-radius: 0.5em;
overflow: hidden;
cursor: grab;
}
.slider:active {
cursor: grabbing;
}
.progress {
width: calc(100 * var(--progress));
height: 100%;
background: lightgreen;
}
.volume {
width: calc(100 * var(--volume));
height: 100%;
background: lightgreen;
}
</style>

View File

@@ -0,0 +1,34 @@
<script lang="ts">
import AudioPlayerComponent from "./AudioPlayerComponent.svelte";
import type { AudioQuestion } from "./games/games";
const path = "/sounds/";
interface Props {
question: AudioQuestion;
showAnswer: boolean;
showQuestion: boolean;
showPlayer: boolean;
[key: string]: unknown;
}
let { question, showAnswer, showQuestion, showPlayer }: Props = $props();
</script>
<div class="mb-4 flex grow flex-col items-center text-6xl">
{#if showQuestion || showAnswer}
<div class="flex grow-1 items-center">
<div class="text-center">{question.data.question}</div>
</div>
{/if}
{#if showPlayer}
<div class="flex w-full flex-col justify-center">
<AudioPlayerComponent src={path + question.data.audio} />
</div>
{/if}
{#if showAnswer}
<div class="flex grow-1 items-center text-center">
{question.data.answer}
</div>
{/if}
</div>

View File

@@ -46,18 +46,27 @@ const games: Games = [
},
{
points: 400,
type: "SIMPLE",
type: "AUDIO",
data: {
question: "Question 4?",
audio: "music.mp3",
answer: "Answer 4"
}
},
{
points: 500,
type: "SIMPLE",
type: "AUDIO_MULTIPLE_CHOICE",
data: {
question: "Question 5?",
answer: "Answer 5"
audio: "music.mp3",
choices: [
"Choice 1",
"Choice 2",
"Choice 3",
"Choice 4",
"Choice 5",
"Choice 6"
]
}
}
]
@@ -1224,7 +1233,12 @@ const games: Games = [
}
];
export type QuestionType = "SIMPLE" | "MULTIPLE_CHOICE" | "IMAGE";
export type QuestionType =
| "SIMPLE"
| "MULTIPLE_CHOICE"
| "IMAGE"
| "AUDIO"
| "AUDIO_MULTIPLE_CHOICE";
export type Question = {
points: number;
@@ -1259,6 +1273,24 @@ export type ImageQuestion = Question & {
};
};
export type AudioQuestion = Question & {
type: "AUDIO";
data: {
question: string;
audio: string;
answer: string;
};
};
export type AudioMultipleChoiceQuestion = Question & {
type: "AUDIO_MULTIPLE_CHOICE";
data: {
question: string;
audio: string;
choices: string[];
};
};
export function isSimpleQuestion(question: Question): question is SimpleQuestion {
return (question as SimpleQuestion).type === "SIMPLE";
}
@@ -1268,10 +1300,24 @@ export function isMultipleChoiceQuestion(question: Question): question is Multip
export function isImageQuestion(question: Question): question is ImageQuestion {
return (question as ImageQuestion).type === "IMAGE";
}
export function isAudioQuestion(question: Question): question is AudioQuestion {
return (question as AudioQuestion).type === "AUDIO";
}
export function isAudioMultipleChoiceQuestion(
question: Question
): question is AudioMultipleChoiceQuestion {
return (question as AudioMultipleChoiceQuestion).type === "AUDIO_MULTIPLE_CHOICE";
}
export type Category = {
name: string;
questions: (SimpleQuestion | MultipleChoiceQuestion | ImageQuestion)[];
questions: (
| SimpleQuestion
| MultipleChoiceQuestion
| ImageQuestion
| AudioQuestion
| AudioMultipleChoiceQuestion
)[];
};
export type Wall = {

View File

@@ -3,12 +3,20 @@
import { page } from "$app/state";
import { error } from "@sveltejs/kit";
import SimpleQuestionComponent from "$lib/SimpleQuestionComponent.svelte";
import { isImageQuestion, isMultipleChoiceQuestion, isSimpleQuestion } from "$lib/games/games";
import {
isAudioMultipleChoiceQuestion,
isAudioQuestion,
isImageQuestion,
isMultipleChoiceQuestion,
isSimpleQuestion
} from "$lib/games/games";
import ws from "$lib/websocket.svelte";
import { MessageType } from "$lib/MessageType";
import { untrack } from "svelte";
import MultipleChoiceQuestionComponent from "$lib/MultipleChoiceQuestionComponent.svelte";
import ImageQuestionComponent from "$lib/ImageQuestionComponent.svelte";
import AudioQuestionComponent from "$lib/AudioQuestionComponent.svelte";
import AudioMultipleChoiceQuestionComponent from "$lib/AudioMultipleChoiceQuestionComponent.svelte";
console.log("wall:", page.params.wall);
@@ -126,6 +134,15 @@
<MultipleChoiceQuestionComponent {question} {showAnswer} {showQuestion} />
{:else if isImageQuestion(question)}
<ImageQuestionComponent {question} {showAnswer} {showQuestion} {isBuzzed} />
{:else if isAudioQuestion(question)}
<AudioQuestionComponent {question} {showAnswer} {showQuestion} showPlayer={false} />
{:else if isAudioMultipleChoiceQuestion(question)}
<AudioMultipleChoiceQuestionComponent
{question}
{showAnswer}
{showQuestion}
showPlayer={false}
/>
{:else}
<p>Type of question unknown</p>
{/if}

View File

@@ -6,7 +6,9 @@
isMultipleChoiceQuestion,
isSimpleQuestion,
isImageQuestion,
type Game
type Game,
isAudioQuestion,
isAudioMultipleChoiceQuestion
} from "$lib/games/games";
import ws from "$lib/websocket.svelte";
import { page } from "$app/state";
@@ -18,6 +20,8 @@
import type { VisitedQuestions } from "$lib/Types.js";
import MultipleChoiceQuestionComponent from "$lib/MultipleChoiceQuestionComponent.svelte";
import ImageQuestionComponent from "$lib/ImageQuestionComponent.svelte";
import AudioQuestionComponent from "$lib/AudioQuestionComponent.svelte";
import AudioMultipleChoiceQuestionComponent from "$lib/AudioMultipleChoiceQuestionComponent.svelte";
let startDisabled = $state(true);
@@ -366,6 +370,20 @@
showQuestion={true}
isBuzzed={false}
/>
{:else if isAudioQuestion(gameManager.question)}
<AudioQuestionComponent
question={gameManager.question}
showAnswer={true}
showPlayer={true}
showQuestion={true}
/>
{:else if isAudioMultipleChoiceQuestion(gameManager.question)}
<AudioMultipleChoiceQuestionComponent
question={gameManager.question}
showAnswer={true}
showPlayer={true}
showQuestion={true}
/>
{:else}
<p>Type of question unknown</p>
{/if}

10
static/images/pause.svg Normal file
View File

@@ -0,0 +1,10 @@
<svg width='24' height='24' viewBox='0 0 24 24' fill='none' xmlns='http://www.w3.org/2000/svg'>
<style>
rect {
fill: white;
stroke: white;
}
</style>
<rect x='7' y='6' width='2' height='12' stroke-width='1.5' stroke-linejoin='round' />
<rect x='15' y='6' width='2' height='12' stroke-width='1.5' stroke-linejoin='round' />
</svg>

After

Width:  |  Height:  |  Size: 396 B

10
static/images/play.svg Normal file
View File

@@ -0,0 +1,10 @@
<svg width='24' height='24' viewBox='0 0 24 24' fill='none' xmlns='http://www.w3.org/2000/svg'>
<style>
path {
fill: white;
stroke: white;
}
</style>
<path d='M7.5 11.9999V5.93774L12.75 8.96884L18 11.9999L12.75 15.031L7.5 18.0621V11.9999Z' stroke-width='1.5'
stroke-linejoin='round' />
</svg>

After

Width:  |  Height:  |  Size: 363 B

BIN
static/sounds/music.mp3 Normal file

Binary file not shown.