diff options
| -rw-r--r-- | .gitignore | 1 | ||||
| -rw-r--r-- | backend/admin/handler.go | 22 | ||||
| -rw-r--r-- | backend/admin/templates/audio.html | 14 | ||||
| -rw-r--r-- | backend/admin/templates/dashboard.html | 3 | ||||
| -rw-r--r-- | frontend/app/.client/audio/AudioController.ts | 94 | ||||
| -rw-r--r-- | frontend/app/.client/audio/SoundEffect.ts | 47 | ||||
| -rw-r--r-- | frontend/app/components/GolfWatchApp.client.tsx | 16 | ||||
| -rw-r--r-- | frontend/app/components/GolfWatchAppWithAudioPlayRequest.client.tsx | 36 | ||||
| -rw-r--r-- | frontend/app/routes/golf.$gameId.watch.tsx | 6 | ||||
| -rwxr-xr-x | scripts/copy-external-assets.sh | 16 |
10 files changed, 248 insertions, 7 deletions
@@ -1 +1,2 @@ /.env +/.external-assets diff --git a/backend/admin/handler.go b/backend/admin/handler.go index 17341d5..a685d16 100644 --- a/backend/admin/handler.go +++ b/backend/admin/handler.go @@ -69,6 +69,7 @@ func (h *Handler) RegisterHandlers(g *echo.Group) { g.GET("/games", h.getGames) g.GET("/games/:gameID", h.getGameEdit) g.POST("/games/:gameID", h.postGameEdit) + g.GET("/audio", h.getAudioTest) } func (h *Handler) getDashboard(c echo.Context) error { @@ -289,3 +290,24 @@ func (h *Handler) postGameEdit(c echo.Context) error { return c.Redirect(http.StatusSeeOther, basePath+"/admin/games") } + +func (h *Handler) getAudioTest(c echo.Context) error { + return c.Render(http.StatusOK, "audio", echo.Map{ + "BasePath": basePath, + "Title": "Audio Test", + "Audio": []echo.Map{ + {"FileName": "EX_33.wav", "Label": "終了"}, + {"FileName": "EX_34.wav", "Label": "勝敗1"}, + {"FileName": "EX_35.wav", "Label": "勝敗2"}, + {"FileName": "EX_36.wav", "Label": "グッド1"}, + {"FileName": "EX_37.wav", "Label": "グッド2"}, + {"FileName": "EX_38.wav", "Label": "グッド3"}, + {"FileName": "EX_39.wav", "Label": "グッド4"}, + {"FileName": "EX_40.wav", "Label": "スコア更新1"}, + {"FileName": "EX_41.wav", "Label": "スコア更新2"}, + {"FileName": "EX_42.wav", "Label": "スコア更新3"}, + {"FileName": "EX_43.wav", "Label": "コンパイルエラー1"}, + {"FileName": "EX_44.wav", "Label": "コンパイルエラー2"}, + }, + }) +} diff --git a/backend/admin/templates/audio.html b/backend/admin/templates/audio.html new file mode 100644 index 0000000..21ec463 --- /dev/null +++ b/backend/admin/templates/audio.html @@ -0,0 +1,14 @@ +{{ template "base.html" . }} + +{{ define "breadcrumb" }} +<a href="{{ .BasePath }}/admin/dashboard">Dashboard</a> +{{ end }} + +{{ define "content" }} + {{ range .Audio }} + <figure> + <figcaption>{{ .Label }}</figcaption> + <audio controls src="{{ $.BasePath }}/files/audio/{{ .FileName }}"></audio> + </figure> + {{ end }} +{{ end }} diff --git a/backend/admin/templates/dashboard.html b/backend/admin/templates/dashboard.html index 15b10ff..0f1fbaf 100644 --- a/backend/admin/templates/dashboard.html +++ b/backend/admin/templates/dashboard.html @@ -7,6 +7,9 @@ <p> <a href="{{ .BasePath }}/admin/games">Games</a> </p> +<p> + <a href="{{ .BasePath }}/admin/audio">Audio Test</a> +</p> <form method="post" action="{{ .BasePath }}/logout"> <button type="submit">Logout</button> </form> diff --git a/frontend/app/.client/audio/AudioController.ts b/frontend/app/.client/audio/AudioController.ts new file mode 100644 index 0000000..6ed6180 --- /dev/null +++ b/frontend/app/.client/audio/AudioController.ts @@ -0,0 +1,94 @@ +import { SoundEffect, getFileUrl } from "./SoundEffect"; + +export class AudioController { + audioElements: Record<SoundEffect, HTMLAudioElement | null>; + + constructor() { + this.audioElements = { + finish: null, + winner_1: null, + winner_2: null, + good_1: null, + good_2: null, + good_3: null, + good_4: null, + new_score_1: null, + new_score_2: null, + new_score_3: null, + compile_error_1: null, + compile_error_2: null, + }; + } + + loadAll(): Promise<void> { + return new Promise((resolve) => { + const files = Object.keys(this.audioElements).map( + (se) => [se as SoundEffect, getFileUrl(se as SoundEffect)] as const, + ); + const totalCount = files.length; + let loadedCount = 0; + + files.forEach(([se, fileUrl]) => { + const audio = new Audio(fileUrl); + + audio.addEventListener( + "canplaythrough", + () => { + loadedCount++; + this.audioElements[se] = audio; + if (loadedCount === totalCount) { + resolve(); + } + }, + { once: true }, + ); + + audio.addEventListener("error", () => { + console.log(`Failed to load audio file: ${fileUrl}`); + // Ignore the error and continue loading other files. + }); + }); + }); + } + + async playSoundEffect(soundEffect: SoundEffect): Promise<void> { + const audio = this.audioElements[soundEffect]; + if (!audio) { + return; + } + audio.currentTime = 0; + await audio.play(); + } + + async playSoundEffectFinish(): Promise<void> { + await this.playSoundEffect("finish"); + } + + async playSoundEffectWinner(winner: 1 | 2): Promise<void> { + await this.playSoundEffect(`winner_${winner}`); + } + + async playSoundEffectGood(): Promise<void> { + const variant = Math.floor(Math.random() * 4) + 1; + if (variant !== 1 && variant !== 2 && variant !== 3 && variant !== 4) { + return; // unreachable + } + return await this.playSoundEffect(`good_${variant}`); + } + + async playSoundEffectNewScore(): Promise<void> { + const variant = Math.floor(Math.random() * 3) + 1; + if (variant !== 1 && variant !== 2 && variant !== 3) { + return; // unreachable + } + return await this.playSoundEffect(`new_score_${variant}`); + } + + async playSoundEffectCompileError(): Promise<void> { + const variant = Math.floor(Math.random() * 2) + 1; + if (variant !== 1 && variant !== 2) { + return; // unreachable + } + return await this.playSoundEffect(`compile_error_${variant}`); + } +} diff --git a/frontend/app/.client/audio/SoundEffect.ts b/frontend/app/.client/audio/SoundEffect.ts new file mode 100644 index 0000000..7e40da6 --- /dev/null +++ b/frontend/app/.client/audio/SoundEffect.ts @@ -0,0 +1,47 @@ +export type SoundEffect = + | "finish" + | "winner_1" + | "winner_2" + | "good_1" + | "good_2" + | "good_3" + | "good_4" + | "new_score_1" + | "new_score_2" + | "new_score_3" + | "compile_error_1" + | "compile_error_2"; + +const BASE_URL = + process.env.NODE_ENV === "development" + ? `http://localhost:8002/iosdc-japan/2024/code-battle/files/audio` + : `/iosdc-japan/2024/code-battle/files/audio`; + +export function getFileUrl(soundEffect: SoundEffect): string { + switch (soundEffect) { + case "finish": + return `${BASE_URL}/EX_33.wav`; + case "winner_1": + return `${BASE_URL}/EX_34.wav`; + case "winner_2": + return `${BASE_URL}/EX_35.wav`; + case "good_1": + return `${BASE_URL}/EX_36.wav`; + case "good_2": + return `${BASE_URL}/EX_37.wav`; + case "good_3": + return `${BASE_URL}/EX_38.wav`; + case "good_4": + return `${BASE_URL}/EX_39.wav`; + case "new_score_1": + return `${BASE_URL}/EX_40.wav`; + case "new_score_2": + return `${BASE_URL}/EX_41.wav`; + case "new_score_3": + return `${BASE_URL}/EX_42.wav`; + case "compile_error_1": + return `${BASE_URL}/EX_43.wav`; + case "compile_error_2": + return `${BASE_URL}/EX_44.wav`; + } +} diff --git a/frontend/app/components/GolfWatchApp.client.tsx b/frontend/app/components/GolfWatchApp.client.tsx index 22f87fa..d09a4ae 100644 --- a/frontend/app/components/GolfWatchApp.client.tsx +++ b/frontend/app/components/GolfWatchApp.client.tsx @@ -1,4 +1,5 @@ import { useEffect, useState } from "react"; +import { AudioController } from "../.client/audio/AudioController"; import type { components } from "../.server/api/schema"; import useWebSocket, { ReadyState } from "../hooks/useWebSocket"; import type { PlayerInfo } from "../models/PlayerInfo"; @@ -14,13 +15,17 @@ type Game = components["schemas"]["Game"]; type GameState = "connecting" | "waiting" | "starting" | "gaming" | "finished"; +export type Props = { + game: Game; + sockToken: string; + audioController: AudioController; +}; + export default function GolfWatchApp({ game, sockToken, -}: { - game: Game; - sockToken: string; -}) { + audioController, +}: Props) { const socketUrl = process.env.NODE_ENV === "development" ? `ws://localhost:8002/iosdc-japan/2024/code-battle/sock/golf/${game.game_id}/watch?token=${sockToken}` @@ -53,6 +58,7 @@ export default function GolfWatchApp({ if (nowSec >= finishedAt) { clearInterval(timer); setGameState("finished"); + audioController.playSoundEffectFinish(); } else { setGameState("gaming"); } @@ -65,7 +71,7 @@ export default function GolfWatchApp({ clearInterval(timer); }; } - }, [gameState, startedAt, game.duration_seconds]); + }, [gameState, startedAt, game.duration_seconds, audioController]); const playerA = game.players[0]; const playerB = game.players[1]; diff --git a/frontend/app/components/GolfWatchAppWithAudioPlayRequest.client.tsx b/frontend/app/components/GolfWatchAppWithAudioPlayRequest.client.tsx new file mode 100644 index 0000000..e299f4b --- /dev/null +++ b/frontend/app/components/GolfWatchAppWithAudioPlayRequest.client.tsx @@ -0,0 +1,36 @@ +import { useState } from "react"; +import { AudioController } from "../.client/audio/AudioController"; +import GolfWatchApp, { type Props } from "./GolfWatchApp.client"; + +export default function GolfWatchAppWithAudioPlayRequest({ + game, + sockToken, +}: Omit<Props, "audioController">) { + const [audioController, setAudioController] = + useState<AudioController | null>(null); + const audioPlayPermitted = audioController !== null; + + if (audioPlayPermitted) { + return ( + <GolfWatchApp + game={game} + sockToken={sockToken} + audioController={audioController} + /> + ); + } else { + return ( + <div> + <button + onClick={async () => { + const audioController = new AudioController(); + await audioController.loadAll(); + setAudioController(audioController); + }} + > + Enable Audio Play + </button> + </div> + ); + } +} diff --git a/frontend/app/routes/golf.$gameId.watch.tsx b/frontend/app/routes/golf.$gameId.watch.tsx index 5edf92f..7e90b2d 100644 --- a/frontend/app/routes/golf.$gameId.watch.tsx +++ b/frontend/app/routes/golf.$gameId.watch.tsx @@ -3,7 +3,7 @@ import { useLoaderData } from "@remix-run/react"; import { ClientOnly } from "remix-utils/client-only"; import { apiGetGame, apiGetToken } from "../.server/api/client"; import { ensureUserLoggedIn } from "../.server/auth"; -import GolfWatchApp from "../components/GolfWatchApp.client"; +import GolfWatchAppWithAudioPlayRequest from "../components/GolfWatchAppWithAudioPlayRequest.client"; import GolfWatchAppConnecting from "../components/GolfWatchApps/GolfWatchAppConnecting"; export const meta: MetaFunction<typeof loader> = ({ data }) => [ @@ -41,7 +41,9 @@ export default function GolfWatch() { return ( <ClientOnly fallback={<GolfWatchAppConnecting />}> - {() => <GolfWatchApp game={game} sockToken={sockToken} />} + {() => ( + <GolfWatchAppWithAudioPlayRequest game={game} sockToken={sockToken} /> + )} </ClientOnly> ); } diff --git a/scripts/copy-external-assets.sh b/scripts/copy-external-assets.sh new file mode 100755 index 0000000..6ec8c65 --- /dev/null +++ b/scripts/copy-external-assets.sh @@ -0,0 +1,16 @@ +container_name=$1 + +docker container exec $container_name mkdir -p /data/files/audio + +docker container cp .external-assets/EX_33.wav $container_name:/data/files/audio +docker container cp .external-assets/EX_34.wav $container_name:/data/files/audio +docker container cp .external-assets/EX_35.wav $container_name:/data/files/audio +docker container cp .external-assets/EX_36.wav $container_name:/data/files/audio +docker container cp .external-assets/EX_37.wav $container_name:/data/files/audio +docker container cp .external-assets/EX_38.wav $container_name:/data/files/audio +docker container cp .external-assets/EX_39.wav $container_name:/data/files/audio +docker container cp .external-assets/EX_40.wav $container_name:/data/files/audio +docker container cp .external-assets/EX_41.wav $container_name:/data/files/audio +docker container cp .external-assets/EX_42.wav $container_name:/data/files/audio +docker container cp .external-assets/EX_43.wav $container_name:/data/files/audio +docker container cp .external-assets/EX_44.wav $container_name:/data/files/audio |
