Skip to content

Feat/frontend/play bgm#245

Open
arkwnet wants to merge 4 commits into
developfrom
feat/frontend/play-bgm
Open

Feat/frontend/play bgm#245
arkwnet wants to merge 4 commits into
developfrom
feat/frontend/play-bgm

Conversation

@arkwnet

@arkwnet arkwnet commented Apr 6, 2026

Copy link
Copy Markdown
Contributor

チケットへのリンク

close #69

やったこと

  • Web Audio APIによるサウンド再生機能の追加
    • 音声周りの処理はWebAudioPlayerコンポーネントで一元管理しています

やらないこと

容量が大きいBGMデータ自体のアップロード

できるようになること(ユーザ目線)

画面右下のボタンでBGMのON/OFFを切り替えられるようになります。暴発を防ぐため初期値はOFF、かつ再読み込みでリセットされます。

できなくなること(ユーザ目線)

無し

動作確認

全機能を目視で確認していますが、レビューされる方はお手元の環境でダブルチェックをお願いします (特に生協指定PCをお持ちの方)

その他

無し


Open with Devin

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 7 new potential issues.

Open in Devin Review

Comment on lines +10 to 12
});

return <RankingTabs />;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Missing [] dependency array causes play() to re-fire on every render, restarting BGM

In Ranking.tsx, useEffect(() => { play("/sounds/bgm0.mp3"); }) has no dependency array, so it runs after every render, not just on mount. Each call to play (in WebAudioPlayer.tsx:55) first calls stop() to halt the current audio, then restarts the track from the beginning. Since play and stop in WebAudioPlayer.tsx:80 are not memoized with useCallback, every render of WebAudioPlayer produces a new context value, triggering consumer re-renders — which in turn re-fire this effect. Concretely, toggling the sound button causes the ranking BGM to restart from the beginning, and any parent re-render will do the same.

Suggested change
});
return <RankingTabs />;
useEffect(() => {
play("/sounds/bgm0.mp3");
}, []);
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +10 to 12
});

return (

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Missing [] dependency array causes stop() to fire on every render

In Home.tsx, useEffect(() => { stop(); }) has no dependency array, so it runs after every render rather than only on mount. While stop() is idempotent when nothing is playing (it checks sourceRef.current), this still means that any re-render caused by context value changes or parent re-renders will unnecessarily invoke stop(). This is inconsistent with the analogous effects in GameResult.tsx:21-23 and GameTyping.tsx:197-211 which correctly use [].

Suggested change
});
return (
useEffect(() => {
stop();
}, []);
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

};
};

return <PlayerContext.Provider value={{ play, stop }}>{children}</PlayerContext.Provider>;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 Info: Context value not memoized — causes unnecessary consumer re-renders

In WebAudioPlayer.tsx:80, the value={{ play, stop }} creates a new object on every render since neither play nor stop are wrapped in useCallback. This means every re-render of WebAudioPlayer (e.g., when isPlay changes) creates a new context value, forcing all useContext(PlayerContext) consumers to re-render. This amplifies the impact of the missing dependency arrays in Ranking.tsx and Home.tsx (reported as bugs), and is generally wasteful. Wrapping play and stop in useCallback (and using an isPlayRef for the isPlay check inside play) would stabilize the context value and prevent unnecessary consumer re-renders.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +11 to +15
<>
<Footer isPlay={isPlay} setIsPlay={setIsPlay} />
<div className="children">
<WebAudioPlayer isPlay={isPlay}>{children}</WebAudioPlayer>
</div>

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 Info: Footer rendered before children — visual ordering relies on CSS fixed positioning

In client.tsx:11-15, the Footer is rendered before the children div in the DOM. The previous layout in layout.tsx also had Footer before children, so this matches the existing pattern. The footer uses position: fixed (Footer.module.scss:6) so DOM order doesn't affect visual layout. Not a bug, just noting the ordering is intentional and CSS-dependent.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines 10 to +11
useEffect(() => {
play("/sounds/bgm1.mp3");

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚩 Stale play closure in game components won't respond to mid-page sound toggle

In GamePre.tsx:11, GameTyping.tsx:199-203, and GameResult.tsx:22, the play function is called inside useEffect hooks with [] or [nextPage] dependency arrays, but play itself is not included as a dependency. Since play captures isPlay via closure (WebAudioPlayer.tsx:53), the version captured at mount time won't reflect later toggles. This means if a user toggles sound ON while already on one of these pages, the BGM won't start (the captured play still has isPlay=false). This is a minor UX limitation rather than a crash-level bug — sound works correctly when navigating to a new page after toggling.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +60 to +64
if (!buffer) {
const response = await fetch(url);
const arrayBuffer = await response.arrayBuffer();
buffer = await ctx.decodeAudioData(arrayBuffer);
bufferCacheRef.current.set(url, buffer);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 Info: No error handling in play for failed fetch or decode

In WebAudioPlayer.tsx:61-63, fetch(url) and ctx.decodeAudioData(arrayBuffer) are both async operations that can throw (e.g., 404 response, invalid audio data), but neither is wrapped in a try/catch. An unhandled promise rejection would bubble up silently. Since play is called from useEffect callbacks which don't handle the returned promise, any rejection becomes an unhandled promise rejection warning. Consider wrapping the async body in a try/catch to prevent this.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

if (isProcessingRef.current) {
return;
}
stop();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 Info: sendResultData uses stop without it in the dependency array

In GameTyping.tsx:57, stop() is called inside sendResultData which is wrapped in useCallback with deps [stats, nextPage, router, setScore] at line 108. stop from useWebAudio() is not included. I verified this is safe because stop (WebAudioPlayer.tsx:33-41) only accesses sourceRef.current, which is a ref and always points to the latest value regardless of closure staleness. So even a stale stop reference correctly stops the current audio source.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

@h-takeyeah

Copy link
Copy Markdown
Collaborator

(提案)<PlayerContext.Provider>という書き方は古いので<PlayerContext>に変更せよ。

現状だとSOUNDの画像以外の何もないところでも画面半分より右にカーソルがあるとcursor: pointerが効いてしまいます。
(提案)Footer.module.cssの.right:hoverでcursor: pointerするよりimg:hoverにつけた方がいいと思います。

isPlayというネーミングはやめた方がいいと思います。
(提案)「BGM再生が有効/無効」の状態を示す、と言った方が実態に近いのでisMutedとかmusicEnabledとかに変えるといいと思います。

isPlayがclient.tsxとWebAudioPlayer()の両方で、別々のStateとして管理されています。
バグのもとにならないか不安です(現状は問題ないけど)。
PlayerContextを用意したのは、AudioContextインスタンスをアプリ全体で常に1つだけ扱えるようにしたいからという意図があると推測します(シングルトン)。
(提案)この仮定が正しいとするなら、関連するデータを全部このコンテキストに入れてしまい、Footer側はuseWebAudioからisPlayの状態を取得するようにするのがいいと思います。
つまり、現状のFooterに渡しているisPlayとsetIsPlayの代替として、isPlayとtoggleを新たにコンテキストに追加します。

// isMuted推し
type PlayerContextType = {
  play: (url: string) => Promise<void>; // ここ実際のシグネチャに合わせてPromiseにしている
  stop: () => void;
  isMuted: boolean; // 内部的にはRefとして管理(Stateにしちゃうと、値の変更に伴ってこのContextの子コンポーネント全体が再レンダリングされてしまうので)
  toggleIsMuted: () => void;
};

こうするとFooterの上にclient.tsxを置いてそこからStateを渡す必要がなくなる(Footerが自分でuseWebAudioすればいいので)ので、client.tsxを削除できます。
(提案)client.tsxを削除して、ファイルが無かった時の様子に戻すとよいと思います。なぜならclient.tsxはNext.js標準外のファイルであり、なるべく置きたくないからです。置くべきではないとまでは言わないけど……くらいの提案。
(推移的に解決される話)型エラーで落ちてるCIが通るようになる。

(弱提案)さらにそこまで行くと、SOUND ON/OFFのボタンだけ別のクライアントコンポーネントとして別モジュール(=別ファイル)に切り出すことで、Footerから"use client"を削除するみたいなこともできてうれしい(なるべくサーバでレンダリングすべきの原則)。やや過剰か。

以下は、弱い提案。

  • 根拠はないけど、気持ち的にsrc/utilsにUIを置きたくない。components/以下のいい感じのところに置きたいよね~。
  • 必要ないところでuseEffect使ってないか点検したいよね~。無責任だけど私には判断つかないので、arkwさんが見て今の状態でいいならよいとしてください。

@h-takeyeah

Copy link
Copy Markdown
Collaborator

あ、書き忘れてたけどSOUND OFFの状態でゲームのページ(/game)に遷移して、GamePre画面中にSOUND ONを押すとGameTyping画面に行くまで音が流れないっていうのはやや直感に反するので直せるなら直したいですね。

例えば、SOUNDのボタンを押したら再生、もう一度押したら停止という動作にするなら(←仮定)
onClickをイベントの起点にできるので、コンポーネントのマウントを起点にする必要がなくなり、結果的にuseEffectの使用を減らせる副次的な効果もあります!

@h-takeyeah

h-takeyeah commented Jun 15, 2026

Copy link
Copy Markdown
Collaborator

別のPRでやるとのことでしたが、bgmはこのPRでチェックインしてもよいと思います。
なぜならファイルは合計してもたかだか6.7MBであり、もうこのコミット以降は更新しないと予想されるからです。

その前提だと普通にチェックインした場合とLFSを使った場合でgit clone時のダウンロードサイズに差がほぼ出ないので(とSonnet 4.6が言っています)。

メモ: もしbgmを追加するなら、できれば他のテキストファイルに合わせて*.mp3のファイルパーミッションも644に変更してからチェックインしてほしい。

@KinjiKawaguchi KinjiKawaguchi left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

連絡いただいてたのに気づけておらず申し訳ないです。

takeyeahさんの構造面の指摘も、Devinの個別バグ指摘もいずれも妥当だと思うので、それらに加えて自分が気になった点は2点です。

</div>
</div>
<div className={styles.right}>
<div className={styles.sound} onClick={toggleSound}>

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

サウンド切り替えを

で実装していますが、これだとキーボード操作不可・role欠如でアクセシブルでないため、要素に変更したほうがよさそうです(cursor:pointer の議論も併せて解決します)。

@KinjiKawaguchi KinjiKawaguchi Jun 15, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

div->buttonに変更依頼。

なぜかレンダーしてくれない

@h-takeyeah h-takeyeah Jun 16, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

buttonに変えるの個人的には是非ってほどじゃないけどいいですね。レンダーされないのはbuttonタグがインライン要素だからかなと思いました。対策としては一長一短ですが、ブロック要素で囲むか、クレジット表記(Shizuoka…)の部分の幅を(float外した上で)縮めるか、buttonをposition: absoluteにしてツリーから外すか…あたりかと(試してはない)。

(追記)cursor: pointerの件を解決できるのでよいとは思いますが、是非やろうとまでは私は考えてません(再掲)。理由はこのtyping-app、全体的にアクセシビリティ考えられてないので今更……と思うのと、buttonにするとブラウザのデフォルトスタイルを剥がすためのCSSが増えてめんどくさそう(小並感)と思うからです。

import WebAudioPlayer from "@/utils/WebAudioPlayer";
import Footer from "../components/organism/Footer";

export default function ClientLayout({ children }) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

このファイルを残す場合は import { ReactNode } from "react" して { children: ReactNode } を付けてください。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[FEATURE]サウンドのトリガーボタンを作成

3 participants