diff --git a/apps/web/src/components/molecules/RecordButton/index.tsx b/apps/web/src/components/molecules/RecordButton/index.tsx
index de32c75f..beca6a48 100644
--- a/apps/web/src/components/molecules/RecordButton/index.tsx
+++ b/apps/web/src/components/molecules/RecordButton/index.tsx
@@ -73,7 +73,7 @@ export function RecordButton({
{/* 背景の光彩エフェクト */}
{/* メインボタン */}
@@ -81,15 +81,25 @@ export function RecordButton({
type="button"
onClick={onClick}
disabled={disabled}
- className={`relative h-32 w-32 rounded-full ${getBackgroundGradient()}shadow-2xl transition-all duration-300 ease-out hover:scale-105 hover:shadow-3xl active:scale-95 disabled:cursor-not-allowed disabled:opacity-50 ${getRingEffect()}
- ${getAnimationClass()}group`}
+ className={`
+ relative w-32 h-32 rounded-full
+ ${getBackgroundGradient()}
+ shadow-2xl
+ transition-all duration-300 ease-out
+ hover:scale-105 hover:shadow-3xl
+ active:scale-95
+ disabled:opacity-50 disabled:cursor-not-allowed
+ ${getRingEffect()}
+ ${getAnimationClass()}
+ group
+ `}
aria-label="録音"
>
{/* グラスエフェクト */}
{/* 内部の光沢 */}
-
+
{/* コンテンツ */}
@@ -100,8 +110,8 @@ export function RecordButton({
{/* 波紋エフェクト(録音中) */}
{status === "recording" && (
<>
-
-
+
+
>
)}
diff --git a/apps/web/src/components/molecules/RecordingControls/index.tsx b/apps/web/src/components/molecules/RecordingControls/index.tsx
new file mode 100644
index 00000000..9be665d4
--- /dev/null
+++ b/apps/web/src/components/molecules/RecordingControls/index.tsx
@@ -0,0 +1,47 @@
+"use client";
+
+import { motion } from "framer-motion";
+import { RippleEffect } from "../../atoms/RippleEffect";
+import type { RecordingControlsProps } from "./types";
+
+/**
+ * 録音コントロールコンポーネント
+ *
+ * @description
+ * 録音の一時停止・停止ボタンを提供するコンポーネント
+ *
+ * @param onStop 停止ボタンクリック時のコールバック
+ * @param isRecording 録音中かどうか
+ */
+export function RecordingControls({
+ onStop,
+ isRecording,
+}: RecordingControlsProps) {
+ return (
+
+
+ {/* 一時停止アイコン */}
+
+
+ {/* リップルエフェクト */}
+
+
+
+ );
+}
diff --git a/apps/web/src/components/molecules/RecordingControls/types.ts b/apps/web/src/components/molecules/RecordingControls/types.ts
new file mode 100644
index 00000000..42957fe7
--- /dev/null
+++ b/apps/web/src/components/molecules/RecordingControls/types.ts
@@ -0,0 +1,14 @@
+/**
+ * RecordingControlsコンポーネントのプロパティ型定義
+ */
+export interface RecordingControlsProps {
+ /**
+ * 停止ボタンクリック時のコールバック
+ */
+ onStop: () => void;
+
+ /**
+ * 録音中かどうか
+ */
+ isRecording: boolean;
+}
diff --git a/apps/web/src/components/molecules/RecordingExpandedDisplay/index.tsx b/apps/web/src/components/molecules/RecordingExpandedDisplay/index.tsx
new file mode 100644
index 00000000..958761f7
--- /dev/null
+++ b/apps/web/src/components/molecules/RecordingExpandedDisplay/index.tsx
@@ -0,0 +1,78 @@
+"use client";
+
+import { motion } from "framer-motion";
+import { RecordingControls } from "../RecordingControls";
+import { RecordingHeader } from "../RecordingHeader";
+import { RecordingTimer } from "../RecordingTimer";
+import { WaveformDisplay } from "../WaveformDisplay";
+import type { RecordingExpandedDisplayProps } from "./types";
+
+/**
+ * 録音拡大表示コンポーネント
+ *
+ * @description
+ * 録音インターフェースの展開時に表示されるコンポーネント
+ *
+ * @param status 録音状態
+ * @param recordingTime 録音時間
+ * @param waveformData 波形データ
+ * @param formatTime 時間フォーマット関数
+ * @param onCancel キャンセルボタンクリック時のコールバック
+ * @param onNext 次へボタンクリック時のコールバック
+ * @param onStop 停止ボタンクリック時のコールバック
+ */
+export function RecordingExpandedDisplay({
+ status,
+ recordingTime,
+ waveformData,
+ formatTime,
+ onCancel,
+ onNext,
+ onStop,
+}: RecordingExpandedDisplayProps) {
+ return (
+
+ {/* ヘッダー部分 */}
+
+
+ {/* メインコンテンツエリア */}
+
+ {/* タイマー表示 */}
+
+
+ {/* 波形表示 */}
+
+
+
+
+
+ {/* 停止ボタン */}
+
+
+ );
+}
diff --git a/apps/web/src/components/molecules/RecordingExpandedDisplay/types.ts b/apps/web/src/components/molecules/RecordingExpandedDisplay/types.ts
new file mode 100644
index 00000000..b958389a
--- /dev/null
+++ b/apps/web/src/components/molecules/RecordingExpandedDisplay/types.ts
@@ -0,0 +1,41 @@
+/**
+ * RecordingExpandedDisplayコンポーネントのプロパティ型定義
+ */
+export interface RecordingExpandedDisplayProps {
+ /**
+ * 録音状態
+ */
+ status: "idle" | "recording" | "completed";
+
+ /**
+ * 録音時間
+ */
+ recordingTime: number;
+
+ /**
+ * 波形データ
+ */
+ waveformData: number[];
+
+ /**
+ * 時間フォーマット関数
+ * @param time - 録音時間(秒)
+ * @returns フォーマットされた時間文字列
+ */
+ formatTime: (time: number) => string;
+
+ /**
+ * キャンセルボタンクリック時のコールバック
+ */
+ onCancel: () => void;
+
+ /**
+ * 次へボタンクリック時のコールバック
+ */
+ onNext: () => void;
+
+ /**
+ * 停止ボタンクリック時のコールバック
+ */
+ onStop: () => void;
+}
diff --git a/apps/web/src/components/molecules/RecordingHeader/index.tsx b/apps/web/src/components/molecules/RecordingHeader/index.tsx
new file mode 100644
index 00000000..b62b304f
--- /dev/null
+++ b/apps/web/src/components/molecules/RecordingHeader/index.tsx
@@ -0,0 +1,49 @@
+"use client";
+
+import { BlinkingIndicator } from "../../atoms/BlinkingIndicator";
+import type { RecordingHeaderProps } from "./types";
+
+/**
+ * 録音インターフェースのヘッダーコンポーネント
+ *
+ * @description
+ * 録音中の状態表示と操作ボタンを含むヘッダー
+ *
+ * @param isRecording 録音中かどうか
+ * @param onCancel キャンセルボタンクリック時のコールバック
+ * @param onNext 次へボタンクリック時のコールバック
+ */
+export function RecordingHeader({
+ isRecording,
+ onCancel,
+ onNext,
+}: RecordingHeaderProps) {
+ return (
+
+
+
+
+
+
+ 録音中
+
+
+
+
+
+ );
+}
diff --git a/apps/web/src/components/molecules/RecordingHeader/types.ts b/apps/web/src/components/molecules/RecordingHeader/types.ts
new file mode 100644
index 00000000..c1d60c84
--- /dev/null
+++ b/apps/web/src/components/molecules/RecordingHeader/types.ts
@@ -0,0 +1,19 @@
+/**
+ * RecordingHeaderコンポーネントのプロパティ型定義
+ */
+export interface RecordingHeaderProps {
+ /**
+ * 録音中かどうか
+ */
+ isRecording: boolean;
+
+ /**
+ * キャンセルボタンクリック時のコールバック
+ */
+ onCancel: () => void;
+
+ /**
+ * 次へボタンクリック時のコールバック
+ */
+ onNext: () => void;
+}
diff --git a/apps/web/src/components/molecules/RecordingInitialState/index.tsx b/apps/web/src/components/molecules/RecordingInitialState/index.tsx
new file mode 100644
index 00000000..34921d84
--- /dev/null
+++ b/apps/web/src/components/molecules/RecordingInitialState/index.tsx
@@ -0,0 +1,26 @@
+"use client";
+
+import { motion } from "framer-motion";
+import { MdMic } from "react-icons/md";
+import type { RecordingInitialStateProps } from "./types";
+
+/**
+ * 録音初期状態コンポーネント
+ *
+ * @description
+ * 録音開始前の初期状態を表示するコンポーネント
+ *
+ * @param onClick 録音ボタンクリック時のコールバック
+ */
+export function RecordingInitialState({ onClick }: RecordingInitialStateProps) {
+ return (
+
+
+
+ );
+}
diff --git a/apps/web/src/components/molecules/RecordingInitialState/types.ts b/apps/web/src/components/molecules/RecordingInitialState/types.ts
new file mode 100644
index 00000000..cf4a24be
--- /dev/null
+++ b/apps/web/src/components/molecules/RecordingInitialState/types.ts
@@ -0,0 +1,9 @@
+/**
+ * RecordingInitialStateコンポーネントのプロパティ型定義
+ */
+export interface RecordingInitialStateProps {
+ /**
+ * 録音ボタンクリック時のコールバック
+ */
+ onClick: () => void;
+}
diff --git a/apps/web/src/components/molecules/RecordingInstructions/index.tsx b/apps/web/src/components/molecules/RecordingInstructions/index.tsx
new file mode 100644
index 00000000..4ac02c0e
--- /dev/null
+++ b/apps/web/src/components/molecules/RecordingInstructions/index.tsx
@@ -0,0 +1,255 @@
+"use client";
+
+import { motion } from "framer-motion";
+import { ConfirmButton } from "../../atoms/ConfirmButton";
+import { ConfirmationComplete } from "../ConfirmationComplete";
+import { InstructionsList } from "../InstructionsList";
+import { SlideToStart } from "../SlideToStart";
+import type { RecordingInstructionsProps } from "./types";
+
+/**
+ * 録音前の説明・確認コンポーネント
+ *
+ * @description
+ * 録音前に表示する説明と確認事項を表示するコンポーネント
+ *
+ * @param instructionItems 説明項目の配列
+ * @param isClosing 閉じるアニメーション中かどうか
+ * @param isAgreed 同意済みかどうか
+ * @param showConfirmationComplete 確認完了画面を表示するかどうか
+ * @param onAgree 同意ボタンクリック時のコールバック
+ * @param onStartRecording 録音開始時のコールバック
+ * @param instructionsRef 外部クリック検知用のref
+ */
+export function RecordingInstructions({
+ instructionItems,
+ isClosing,
+ isAgreed,
+ showConfirmationComplete,
+ onAgree,
+ onStartRecording,
+ instructionsRef,
+}: RecordingInstructionsProps) {
+ return (
+
= 640
+ ? "5rem"
+ : "4rem",
+ backgroundColor: "rgba(0, 0, 0, 0.95)",
+ borderRadius: "2rem",
+ scale: 1,
+ }}
+ animate={
+ isClosing
+ ? {
+ // 閉じる時:高さから幅の順序で2段階アニメーション
+ height: [
+ "auto",
+ typeof window !== "undefined" && window.innerWidth >= 640
+ ? "5rem"
+ : "4rem",
+ ],
+ width: ["90vw", "12rem"],
+ backgroundColor: [
+ "rgba(30, 30, 30, 0.95)",
+ "rgba(0, 0, 0, 0.95)",
+ ],
+ borderRadius: ["2rem", "2rem"],
+ scale: [1, 0.98, 1],
+ }
+ : {
+ // 開く時:幅から高さの順序で2段階アニメーション
+ width: ["12rem", "90vw"],
+ height: [
+ typeof window !== "undefined" && window.innerWidth >= 640
+ ? "5rem"
+ : "4rem",
+ "auto",
+ ],
+ backgroundColor: [
+ "rgba(0, 0, 0, 0.95)",
+ "rgba(20, 20, 20, 0.96)",
+ "rgba(30, 30, 30, 0.95)",
+ ],
+ borderRadius: ["2rem", "1.9rem", "2rem"],
+ scale: [1, 1.02, 1],
+ }
+ }
+ transition={
+ isClosing
+ ? {
+ // 閉じる時:高さを先に変化させてから幅を変化
+ height: {
+ duration: 0.6,
+ ease: [0.4, 0, 0.2, 1],
+ },
+ width: {
+ duration: 0.6,
+ delay: 0.6,
+ ease: [0.4, 0, 0.2, 1],
+ },
+ backgroundColor: {
+ duration: 1.2,
+ ease: [0.4, 0, 0.2, 1],
+ },
+ borderRadius: {
+ duration: 1.2,
+ ease: [0.4, 0, 0.2, 1],
+ },
+ scale: {
+ duration: 1.2,
+ ease: [0.4, 0, 0.2, 1],
+ },
+ }
+ : {
+ // 開く時:幅を先に変化させてから高さを変化
+ width: {
+ duration: 0.5,
+ ease: [0.4, 0, 0.2, 1],
+ },
+ height: {
+ duration: 0.5,
+ delay: 0.5,
+ ease: [0.4, 0, 0.2, 1],
+ },
+ backgroundColor: {
+ duration: 1.0,
+ ease: [0.4, 0, 0.2, 1],
+ },
+ borderRadius: {
+ duration: 1.0,
+ ease: [0.4, 0, 0.2, 1],
+ },
+ scale: {
+ duration: 1.0,
+ ease: [0.4, 0, 0.2, 1],
+ },
+ }
+ }
+ className="shadow-[0_20px_60px_rgba(0,0,0,0.5)] backdrop-blur-xl border border-neutral-600/30 p-4 sm:p-6 mb-5 overflow-hidden flex flex-col relative max-w-sm mx-auto"
+ style={{
+ backdropFilter: "blur(20px)",
+ WebkitBackdropFilter: "blur(20px)",
+ maxHeight: "80vh",
+ willChange: "transform, width, height, background-color",
+ transform: "translate3d(0, 0, 0)",
+ backfaceVisibility: "hidden",
+ WebkitFontSmoothing: "antialiased",
+ }}
+ >
+ {/* シンプルなグロー効果 */}
+
+
+ {/* ヘッダー(確認事項表示時のみ) */}
+ {!showConfirmationComplete && (
+
+
+ 録音前の確認
+
+
+ 以下の項目をご確認ください
+
+
+ )}
+
+ {/* 確認事項リスト */}
+ {!showConfirmationComplete ? (
+ <>
+
+
+ {/* 確認ボタン */}
+
+ >
+ ) : (
+ <>
+ {/* 確認完了画面 */}
+
+
+ {/* スライドバー(確認完了後のみ表示) */}
+
+
+
+ >
+ )}
+
+ );
+}
diff --git a/apps/web/src/components/molecules/RecordingInstructions/types.ts b/apps/web/src/components/molecules/RecordingInstructions/types.ts
new file mode 100644
index 00000000..2bba9b4f
--- /dev/null
+++ b/apps/web/src/components/molecules/RecordingInstructions/types.ts
@@ -0,0 +1,41 @@
+import type { MutableRefObject } from "react";
+
+/**
+ * RecordingInstructionsコンポーネントのプロパティ型定義
+ */
+export interface RecordingInstructionsProps {
+ /**
+ * 説明項目の配列
+ */
+ instructionItems: string[];
+
+ /**
+ * 閉じるアニメーション中かどうか
+ */
+ isClosing: boolean;
+
+ /**
+ * 同意済みかどうか
+ */
+ isAgreed: boolean;
+
+ /**
+ * 確認完了画面を表示するかどうか
+ */
+ showConfirmationComplete: boolean;
+
+ /**
+ * 同意ボタンクリック時のコールバック
+ */
+ onAgree: () => void;
+
+ /**
+ * 録音開始時のコールバック
+ */
+ onStartRecording: () => void;
+
+ /**
+ * 外部クリック検知用のref
+ */
+ instructionsRef: MutableRefObject
;
+}
diff --git a/apps/web/src/components/molecules/RecordingMiniDisplay/index.tsx b/apps/web/src/components/molecules/RecordingMiniDisplay/index.tsx
new file mode 100644
index 00000000..be15a076
--- /dev/null
+++ b/apps/web/src/components/molecules/RecordingMiniDisplay/index.tsx
@@ -0,0 +1,114 @@
+"use client";
+
+import { motion } from "framer-motion";
+import { MdStop } from "react-icons/md";
+import { PulseEffect } from "../../atoms/PulseEffect";
+import { WaveformDisplay } from "../WaveformDisplay";
+import type { RecordingMiniDisplayProps } from "./types";
+
+/**
+ * 録音ミニ表示コンポーネント
+ *
+ * @description
+ * 録音インターフェースの非展開時に表示されるミニ表示コンポーネント
+ *
+ * @param status 録音状態
+ * @param recordingTime 録音時間
+ * @param waveformData 波形データ
+ * @param formatTime 時間フォーマット関数
+ * @param onStop 停止ボタンクリック時のコールバック
+ */
+export function RecordingMiniDisplay({
+ status,
+ recordingTime,
+ waveformData,
+ formatTime,
+ onStop,
+}: RecordingMiniDisplayProps) {
+ return (
+
+ {/* 録音ボタン */}
+
{
+ console.log("録音ボタンがクリックされました", { status });
+ if (status === "recording") {
+ onStop();
+ }
+ }}
+ className={`
+ relative rounded-full flex items-center justify-center
+ transition-all duration-300 shadow-lg touch-manipulation
+ w-14 h-14 sm:w-16 sm:h-16
+ ${
+ status === "recording"
+ ? "bg-red-600 hover:bg-red-700"
+ : status === "completed"
+ ? "bg-gray-400 cursor-not-allowed"
+ : "bg-gray-600 hover:bg-gray-700"
+ }
+ `}
+ style={{
+ cursor: status === "completed" ? "not-allowed" : "pointer",
+ }}
+ whileTap={status !== "completed" ? { scale: 0.95 } : {}}
+ whileHover={status !== "completed" ? { scale: 1.05 } : {}}
+ disabled={status === "completed"}
+ >
+ {status === "recording" ? (
+
+ ) : status === "completed" ? (
+
+ ) : (
+
+ )}
+
+ {/* 録音中のパルスエフェクト */}
+
+
+
+ {/* 波形表示 */}
+
+
+
+
+ {/* タイマー */}
+
+
+ {formatTime(recordingTime)}
+
+
+ {status === "recording" ? "録音中" : "完了"}
+
+
+
+ );
+}
diff --git a/apps/web/src/components/molecules/RecordingMiniDisplay/types.ts b/apps/web/src/components/molecules/RecordingMiniDisplay/types.ts
new file mode 100644
index 00000000..4c05c1d3
--- /dev/null
+++ b/apps/web/src/components/molecules/RecordingMiniDisplay/types.ts
@@ -0,0 +1,31 @@
+/**
+ * RecordingMiniDisplayコンポーネントのプロパティ型定義
+ */
+export interface RecordingMiniDisplayProps {
+ /**
+ * 録音状態
+ */
+ status: "idle" | "recording" | "completed";
+
+ /**
+ * 録音時間
+ */
+ recordingTime: number;
+
+ /**
+ * 波形データ
+ */
+ waveformData: number[];
+
+ /**
+ * 時間フォーマット関数
+ * @param time - 録音時間(秒)
+ * @returns フォーマットされた時間文字列
+ */
+ formatTime: (time: number) => string;
+
+ /**
+ * 停止ボタンクリック時のコールバック
+ */
+ onStop: () => void;
+}
diff --git a/apps/web/src/components/molecules/RecordingTimer/index.tsx b/apps/web/src/components/molecules/RecordingTimer/index.tsx
new file mode 100644
index 00000000..4d75c76d
--- /dev/null
+++ b/apps/web/src/components/molecules/RecordingTimer/index.tsx
@@ -0,0 +1,28 @@
+"use client";
+
+import { motion } from "framer-motion";
+import type { RecordingTimerProps } from "./types";
+
+/**
+ * 録音タイマーコンポーネント
+ *
+ * @description
+ * 録音時間を大きく表示するコンポーネント
+ *
+ * @param time 録音時間(秒)
+ * @param formatTime 時間フォーマット関数
+ */
+export function RecordingTimer({ time, formatTime }: RecordingTimerProps) {
+ return (
+
+
+ {formatTime(time)}
+
+
+ );
+}
diff --git a/apps/web/src/components/molecules/RecordingTimer/types.ts b/apps/web/src/components/molecules/RecordingTimer/types.ts
new file mode 100644
index 00000000..1bd7289b
--- /dev/null
+++ b/apps/web/src/components/molecules/RecordingTimer/types.ts
@@ -0,0 +1,16 @@
+/**
+ * RecordingTimerコンポーネントのプロパティ型定義
+ */
+export interface RecordingTimerProps {
+ /**
+ * 録音時間(秒)
+ */
+ time: number;
+
+ /**
+ * 時間をフォーマットする関数
+ * @param time - 録音時間(秒)
+ * @returns フォーマットされた時間文字列
+ */
+ formatTime: (time: number) => string;
+}
diff --git a/apps/web/src/components/organisms/RecordingContainer/index.tsx b/apps/web/src/components/organisms/RecordingContainer/index.tsx
new file mode 100644
index 00000000..02f71ab0
--- /dev/null
+++ b/apps/web/src/components/organisms/RecordingContainer/index.tsx
@@ -0,0 +1,63 @@
+"use client";
+
+import { motion } from "framer-motion";
+import type { RecordingContainerProps } from "./types";
+
+/**
+ * 録音コンテナコンポーネント
+ *
+ * @description
+ * 録音中のUIをラップするコンテナコンポーネント
+ *
+ * @param children 子要素
+ * @param isExpanded 展開されているかどうか
+ * @param constraintsRef ドラッグ制約用のref
+ * @param onDragEnd ドラッグ終了時のコールバック
+ * @param onToggleExpand 展開/折りたたみ切り替え時のコールバック
+ */
+export function RecordingContainer({
+ children,
+ isExpanded,
+ constraintsRef,
+ onDragEnd,
+ onToggleExpand,
+}: RecordingContainerProps) {
+ return (
+
+
+ {/* ドラッグハンドル */}
+
+
+ {/* メインコンテンツ */}
+
+ {children}
+
+
+
+ );
+}
diff --git a/apps/web/src/components/organisms/RecordingContainer/types.ts b/apps/web/src/components/organisms/RecordingContainer/types.ts
new file mode 100644
index 00000000..cd5e16a0
--- /dev/null
+++ b/apps/web/src/components/organisms/RecordingContainer/types.ts
@@ -0,0 +1,35 @@
+import type { PanInfo } from "framer-motion";
+import type { MutableRefObject, ReactNode } from "react";
+
+/**
+ * RecordingContainerコンポーネントのプロパティ型定義
+ */
+export interface RecordingContainerProps {
+ /**
+ * 子要素
+ */
+ children: ReactNode;
+
+ /**
+ * 展開されているかどうか
+ */
+ isExpanded: boolean;
+
+ /**
+ * ドラッグ制約用のref
+ */
+ constraintsRef: MutableRefObject;
+
+ /**
+ * ドラッグ終了時のコールバック
+ */
+ onDragEnd: (
+ _event: MouseEvent | TouchEvent | PointerEvent,
+ info: PanInfo,
+ ) => void;
+
+ /**
+ * 展開/折りたたみ切り替え時のコールバック
+ */
+ onToggleExpand: () => void;
+}
diff --git a/apps/web/src/components/organisms/RecordingInterface/hooks/useRecordingInterface.ts b/apps/web/src/components/organisms/RecordingInterface/hooks/useRecordingInterface.ts
index cb52e7f1..8b7b4b83 100644
--- a/apps/web/src/components/organisms/RecordingInterface/hooks/useRecordingInterface.ts
+++ b/apps/web/src/components/organisms/RecordingInterface/hooks/useRecordingInterface.ts
@@ -1,11 +1,11 @@
-'use client'
+"use client";
-import type { PanInfo } from 'framer-motion'
-import { useEffect, useRef, useState } from 'react'
-import { useRecorderStore } from '../../../../store/useRecorderStore'
+import type { PanInfo } from "framer-motion";
+import { useEffect, useRef, useState } from "react";
+import { useRecorderStore } from "../../../../store/useRecorderStore";
// 実際のMediaRecorder APIを使用
-import { useMediaRecorder } from '../../RecordSection/hooks/useMediaRecorder'
-import { useAsyncWaveform } from './useAsyncWaveform'
+import { useMediaRecorder } from "../../RecordSection/hooks/useMediaRecorder";
+import { useAsyncWaveform } from "./useAsyncWaveform";
/**
* RecordingInterfaceで使用する状態と機能をまとめたカスタムフック
@@ -14,228 +14,228 @@ import { useAsyncWaveform } from './useAsyncWaveform'
* @returns 録音インターフェースで使用する状態と機能
*/
export function useRecordingInterface(
- onExpandedChange?: (isExpanded: boolean) => void,
+ onExpandedChange?: (isExpanded: boolean) => void,
) {
- const [isExpanded, setIsExpanded] = useState(false)
- const [status, setStatus] = useState<'idle' | 'recording' | 'completed'>(
- 'idle',
- )
- const [recordingTime, setRecordingTime] = useState(0)
- const [showInstructions, setShowInstructions] = useState(false)
- const [isClosing, setIsClosing] = useState(false)
- const [showPlayback, setShowPlayback] = useState(false)
- const [isAgreed, setIsAgreed] = useState(false)
- const [showConfirmationComplete, setShowConfirmationComplete] =
- useState(false)
-
- // 実際のMediaRecorder APIを使用
- const {
- startRecording,
- stopRecording,
- error: recordingError,
- } = useMediaRecorder()
- const { audioData } = useRecorderStore()
- const constraintsRef = useRef(null)
-
- // 非同期波形データフック
- const waveformData = useAsyncWaveform(status === 'recording')
-
- // 外部クリック検知用のref
- const instructionsRef = useRef(null)
-
- // 外部クリック検知
- useEffect(() => {
- if (!showInstructions) return
-
- const handleClickOutside = (event: MouseEvent | TouchEvent) => {
- if (
- instructionsRef.current &&
- !instructionsRef.current.contains(event.target as Node)
- ) {
- handleCloseInstructions()
- }
- }
-
- // イベントリスナーを追加(少し遅延させて、開くアニメーション中のクリックを無視)
- const timeoutId = setTimeout(() => {
- document.addEventListener('mousedown', handleClickOutside)
- document.addEventListener('touchstart', handleClickOutside)
- }, 300)
-
- return () => {
- clearTimeout(timeoutId)
- document.removeEventListener('mousedown', handleClickOutside)
- document.removeEventListener('touchstart', handleClickOutside)
- }
- }, [showInstructions])
-
- // 録音時間のカウント(requestAnimationFrameを使用)
- useEffect(() => {
- if (status !== 'recording') return
-
- let animationId: number
- let lastTime = performance.now()
-
- const updateTime = (currentTime: number) => {
- const deltaTime = (currentTime - lastTime) / 1000 // ミリ秒を秒に変換
- lastTime = currentTime
-
- setRecordingTime((prev) => {
- const newTime = prev + deltaTime
- return newTime >= 10 ? 10 : newTime
- })
-
- animationId = requestAnimationFrame(updateTime)
- }
-
- animationId = requestAnimationFrame(updateTime)
-
- return () => {
- cancelAnimationFrame(animationId)
- }
- }, [status])
-
- // 10秒で自動停止
- useEffect(() => {
- if (recordingTime >= 10 && status === 'recording') {
- handleStop()
- }
- }, [recordingTime, status])
-
- // 展開状態が変更されたときに親コンポーネントに通知
- useEffect(() => {
- onExpandedChange?.(isExpanded && status !== 'idle')
- }, [isExpanded, status, onExpandedChange])
-
- // 録音完了後、audioDataが設定されたら再生画面を表示
- useEffect(() => {
- if (status === 'completed' && audioData) {
- setShowPlayback(true)
- setStatus('idle')
- setRecordingTime(0)
- setIsExpanded(false)
- // 確認関連の状態をリセット
- setIsAgreed(false)
- setShowConfirmationComplete(false)
- setShowInstructions(false)
- }
- }, [status, audioData])
-
- const handleRecord = async () => {
- // 注意書きを表示
- setShowInstructions(true)
- }
-
- const handleStartRecording = async () => {
- try {
- setStatus('recording')
- setRecordingTime(0)
- setShowInstructions(false)
- // 確認関連の状態をリセット
- setIsAgreed(false)
- setShowConfirmationComplete(false)
- await startRecording()
- } catch (error) {
- console.error('録音の開始に失敗しました:', error)
- setStatus('idle')
- setShowInstructions(false)
- // エラーメッセージを表示(将来的にはUIで表示)
- alert(
- `録音の開始に失敗しました: ${error instanceof Error ? error.message : '不明なエラー'}`,
- )
- }
- }
-
- const handleAgree = () => {
- setIsAgreed(true)
- // 確認ボタンのアニメーション完了まで待ってから確認完了画面を表示
- setTimeout(() => {
- setShowConfirmationComplete(true)
- }, 1200)
- }
-
- const handleStop = async () => {
- try {
- setStatus('completed')
- await stopRecording()
- } catch (error) {
- console.error('録音の停止に失敗しました:', error)
- setStatus('idle')
- }
- }
-
- const formatTime = (time: number) => {
- const minutes = Math.floor(time / 60)
- const seconds = Math.floor(time % 60)
- const milliseconds = Math.floor((time % 1) * 100)
- return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}.${milliseconds.toString().padStart(2, '0')}`
- }
-
- const handleDragEnd = (
- _event: MouseEvent | TouchEvent | PointerEvent,
- info: PanInfo,
- ) => {
- if (info.offset.y < -50) {
- setIsExpanded(true)
- } else if (info.offset.y > 50) {
- setIsExpanded(false)
- }
- }
-
- const handleClosePlayback = () => {
- setShowPlayback(false)
- // 次回の録音のために確認関連の状態をリセット
- setIsAgreed(false)
- setShowConfirmationComplete(false)
- setShowInstructions(false)
- setIsClosing(false)
- }
-
- const handleCloseInstructions = () => {
- setIsClosing(true)
- // アニメーション完了後に状態をリセット
- setTimeout(() => {
- setShowInstructions(false)
- setIsClosing(false)
- setIsAgreed(false)
- setShowConfirmationComplete(false)
- }, 1200) // クローズアニメーションの時間に合わせる(0.6 + 0.6 = 1.2秒)
- }
-
- const instructionItems = [
- 'マイクへのアクセス許可が必要です',
- '録音は最大10秒まで自動停止します',
- '録音中にもう一度ボタンを押すと録音を停止します',
- '周囲の雑音が多いと AI 分類の精度が低下する場合があります',
- ]
-
- return {
- isExpanded,
- setIsExpanded,
- status,
- setStatus,
- recordingTime,
- showInstructions,
- setShowInstructions,
- isClosing,
- setIsClosing,
- showPlayback,
- isAgreed,
- showConfirmationComplete,
- setShowConfirmationComplete,
- constraintsRef,
- instructionsRef,
- waveformData,
- audioData,
- handleRecord,
- handleStartRecording,
- handleAgree,
- handleStop,
- handleClosePlayback,
- handleCloseInstructions,
- formatTime,
- handleDragEnd,
- instructionItems,
- recordingError,
- }
+ const [isExpanded, setIsExpanded] = useState(false);
+ const [status, setStatus] = useState<"idle" | "recording" | "completed">(
+ "idle",
+ );
+ const [recordingTime, setRecordingTime] = useState(0);
+ const [showInstructions, setShowInstructions] = useState(false);
+ const [isClosing, setIsClosing] = useState(false);
+ const [showPlayback, setShowPlayback] = useState(false);
+ const [isAgreed, setIsAgreed] = useState(false);
+ const [showConfirmationComplete, setShowConfirmationComplete] =
+ useState(false);
+
+ // 実際のMediaRecorder APIを使用
+ const {
+ startRecording,
+ stopRecording,
+ error: recordingError,
+ } = useMediaRecorder();
+ const { audioData } = useRecorderStore();
+ const constraintsRef = useRef(null);
+
+ // 非同期波形データフック
+ const waveformData = useAsyncWaveform(status === "recording");
+
+ // 外部クリック検知用のref
+ const instructionsRef = useRef(null);
+
+ // 外部クリック検知
+ useEffect(() => {
+ if (!showInstructions) return;
+
+ const handleClickOutside = (event: MouseEvent | TouchEvent) => {
+ if (
+ instructionsRef.current &&
+ !instructionsRef.current.contains(event.target as Node)
+ ) {
+ handleCloseInstructions();
+ }
+ };
+
+ // イベントリスナーを追加(少し遅延させて、開くアニメーション中のクリックを無視)
+ const timeoutId = setTimeout(() => {
+ document.addEventListener("mousedown", handleClickOutside);
+ document.addEventListener("touchstart", handleClickOutside);
+ }, 300);
+
+ return () => {
+ clearTimeout(timeoutId);
+ document.removeEventListener("mousedown", handleClickOutside);
+ document.removeEventListener("touchstart", handleClickOutside);
+ };
+ }, [showInstructions]);
+
+ // 録音時間のカウント(requestAnimationFrameを使用)
+ useEffect(() => {
+ if (status !== "recording") return;
+
+ let animationId: number;
+ let lastTime = performance.now();
+
+ const updateTime = (currentTime: number) => {
+ const deltaTime = (currentTime - lastTime) / 1000; // ミリ秒を秒に変換
+ lastTime = currentTime;
+
+ setRecordingTime((prev) => {
+ const newTime = prev + deltaTime;
+ return newTime >= 10 ? 10 : newTime;
+ });
+
+ animationId = requestAnimationFrame(updateTime);
+ };
+
+ animationId = requestAnimationFrame(updateTime);
+
+ return () => {
+ cancelAnimationFrame(animationId);
+ };
+ }, [status]);
+
+ // 10秒で自動停止
+ useEffect(() => {
+ if (recordingTime >= 10 && status === "recording") {
+ handleStop();
+ }
+ }, [recordingTime, status]);
+
+ // 展開状態が変更されたときに親コンポーネントに通知
+ useEffect(() => {
+ onExpandedChange?.(isExpanded && status !== "idle");
+ }, [isExpanded, status, onExpandedChange]);
+
+ // 録音完了後、audioDataが設定されたら再生画面を表示
+ useEffect(() => {
+ if (status === "completed" && audioData) {
+ setShowPlayback(true);
+ setStatus("idle");
+ setRecordingTime(0);
+ setIsExpanded(false);
+ // 確認関連の状態をリセット
+ setIsAgreed(false);
+ setShowConfirmationComplete(false);
+ setShowInstructions(false);
+ }
+ }, [status, audioData]);
+
+ const handleRecord = async () => {
+ // 注意書きを表示
+ setShowInstructions(true);
+ };
+
+ const handleStartRecording = async () => {
+ try {
+ setStatus("recording");
+ setRecordingTime(0);
+ setShowInstructions(false);
+ // 確認関連の状態をリセット
+ setIsAgreed(false);
+ setShowConfirmationComplete(false);
+ await startRecording();
+ } catch (error) {
+ console.error("録音の開始に失敗しました:", error);
+ setStatus("idle");
+ setShowInstructions(false);
+ // エラーメッセージを表示(将来的にはUIで表示)
+ alert(
+ `録音の開始に失敗しました: ${error instanceof Error ? error.message : "不明なエラー"}`,
+ );
+ }
+ };
+
+ const handleAgree = () => {
+ setIsAgreed(true);
+ // 確認ボタンのアニメーション完了まで待ってから確認完了画面を表示
+ setTimeout(() => {
+ setShowConfirmationComplete(true);
+ }, 1200);
+ };
+
+ const handleStop = async () => {
+ try {
+ setStatus("completed");
+ await stopRecording();
+ } catch (error) {
+ console.error("録音の停止に失敗しました:", error);
+ setStatus("idle");
+ }
+ };
+
+ const formatTime = (time: number) => {
+ const minutes = Math.floor(time / 60);
+ const seconds = Math.floor(time % 60);
+ const milliseconds = Math.floor((time % 1) * 100);
+ return `${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}.${milliseconds.toString().padStart(2, "0")}`;
+ };
+
+ const handleDragEnd = (
+ _event: MouseEvent | TouchEvent | PointerEvent,
+ info: PanInfo,
+ ) => {
+ if (info.offset.y < -50) {
+ setIsExpanded(true);
+ } else if (info.offset.y > 50) {
+ setIsExpanded(false);
+ }
+ };
+
+ const handleClosePlayback = () => {
+ setShowPlayback(false);
+ // 次回の録音のために確認関連の状態をリセット
+ setIsAgreed(false);
+ setShowConfirmationComplete(false);
+ setShowInstructions(false);
+ setIsClosing(false);
+ };
+
+ const handleCloseInstructions = () => {
+ setIsClosing(true);
+ // アニメーション完了後に状態をリセット
+ setTimeout(() => {
+ setShowInstructions(false);
+ setIsClosing(false);
+ setIsAgreed(false);
+ setShowConfirmationComplete(false);
+ }, 1200); // クローズアニメーションの時間に合わせる(0.6 + 0.6 = 1.2秒)
+ };
+
+ const instructionItems = [
+ "マイクへのアクセス許可が必要です",
+ "録音は最大10秒まで自動停止します",
+ "録音中にもう一度ボタンを押すと録音を停止します",
+ "周囲の雑音が多いと AI 分類の精度が低下する場合があります",
+ ];
+
+ return {
+ isExpanded,
+ setIsExpanded,
+ status,
+ setStatus,
+ recordingTime,
+ showInstructions,
+ setShowInstructions,
+ isClosing,
+ setIsClosing,
+ showPlayback,
+ isAgreed,
+ showConfirmationComplete,
+ setShowConfirmationComplete,
+ constraintsRef,
+ instructionsRef,
+ waveformData,
+ audioData,
+ handleRecord,
+ handleStartRecording,
+ handleAgree,
+ handleStop,
+ handleClosePlayback,
+ handleCloseInstructions,
+ formatTime,
+ handleDragEnd,
+ instructionItems,
+ recordingError,
+ };
}
diff --git a/apps/web/src/components/organisms/RecordingInterface/index.tsx b/apps/web/src/components/organisms/RecordingInterface/index.tsx
index 776544e0..8f9b4444 100644
--- a/apps/web/src/components/organisms/RecordingInterface/index.tsx
+++ b/apps/web/src/components/organisms/RecordingInterface/index.tsx
@@ -1,19 +1,14 @@
"use client";
import { AnimatePresence, motion } from "framer-motion";
-import { MdMic, MdStop } from "react-icons/md";
-import { BlinkingIndicator } from "../../atoms/BlinkingIndicator";
-import { ConfirmButton } from "../../atoms/ConfirmButton";
-import { PulseEffect } from "../../atoms/PulseEffect";
-import { RippleEffect } from "../../atoms/RippleEffect";
-import { ConfirmationComplete } from "../../molecules/ConfirmationComplete";
-import { InstructionsList } from "../../molecules/InstructionsList";
-import { SlideToStart } from "../../molecules/SlideToStart";
-import { WaveformDisplay } from "../../molecules/WaveformDisplay";
+import { RecordingExpandedDisplay } from "../../molecules/RecordingExpandedDisplay";
+import { RecordingInitialState } from "../../molecules/RecordingInitialState";
+import { RecordingInstructions } from "../../molecules/RecordingInstructions";
+import { RecordingMiniDisplay } from "../../molecules/RecordingMiniDisplay";
import { AudioPlayback } from "../AudioPlayback";
+import { RecordingContainer } from "../RecordingContainer";
import { useRecordingInterface } from "./hooks/useRecordingInterface";
import type { RecordingInterfaceProps } from "./type";
-
/**
* 録音インターフェースコンポーネント
*
@@ -27,539 +22,110 @@ import type { RecordingInterfaceProps } from "./type";
*/
export function RecordingInterface({
className = "",
- onExpandedChange,
- currentPosition,
+ onExpandedChange,
+ currentPosition,
}: RecordingInterfaceProps) {
- const {
- isExpanded,
- setIsExpanded,
- status,
- recordingTime,
- showInstructions,
- isClosing,
- showPlayback,
- isAgreed,
- showConfirmationComplete,
- constraintsRef,
- instructionsRef,
- waveformData,
- audioData,
- handleRecord,
- handleStartRecording,
- handleAgree,
- handleStop,
- handleClosePlayback,
- formatTime,
- handleDragEnd,
- instructionItems,
+ const {
+ isExpanded,
+ setIsExpanded,
+ status,
+ recordingTime,
+ showInstructions,
+ isClosing,
+ showPlayback,
+ isAgreed,
+ showConfirmationComplete,
+ constraintsRef,
+ instructionsRef,
+ waveformData,
+ audioData,
+ handleRecord,
+ handleStartRecording,
+ handleAgree,
+ handleStop,
+ handleClosePlayback,
+ formatTime,
+ handleDragEnd,
+ instructionItems,
} = useRecordingInterface(onExpandedChange);
- return (
-
- {/* 初期状態の録音ボタン(録音していない時のみ表示) */}
-
+ >
+ {/* 初期状態の録音ボタン(録音していない時のみ表示) */}
+
{status === "idle" && (
-
- {!showInstructions ? (
-
-
-
+
+ {!showInstructions ? (
+
) : (
- = 640
- ? "5rem"
- : "4rem",
- backgroundColor: "rgba(0, 0, 0, 0.95)",
- borderRadius: "2rem",
- scale: 1,
- }}
- animate={
- isClosing
- ? {
- // 閉じる時:高さから幅の順序で2段階アニメーション
- height: [
- "auto",
- typeof window !== "undefined" &&
- window.innerWidth >= 640
- ? "5rem"
- : "4rem",
- ],
- width: ["90vw", "12rem"],
- backgroundColor: [
- "rgba(30, 30, 30, 0.95)",
- "rgba(0, 0, 0, 0.95)",
- ],
- borderRadius: ["2rem", "2rem"],
- scale: [1, 0.98, 1],
- }
- : {
- // 開く時:幅から高さの順序で2段階アニメーション
- width: ["12rem", "90vw"],
- height: [
- typeof window !== "undefined" &&
- window.innerWidth >= 640
- ? "5rem"
- : "4rem",
- "auto",
- ],
- backgroundColor: [
- "rgba(0, 0, 0, 0.95)",
- "rgba(20, 20, 20, 0.96)",
- "rgba(30, 30, 30, 0.95)",
- ],
- borderRadius: ["2rem", "1.9rem", "2rem"],
- scale: [1, 1.02, 1],
- }
- }
- transition={
- isClosing
- ? {
- // 閉じる時:高さを先に変化させてから幅を変化
- height: {
- duration: 0.6,
- ease: [0.4, 0, 0.2, 1],
- },
- width: {
- duration: 0.6,
- delay: 0.6,
- ease: [0.4, 0, 0.2, 1],
- },
- backgroundColor: {
- duration: 1.2,
- ease: [0.4, 0, 0.2, 1],
- },
- borderRadius: {
- duration: 1.2,
- ease: [0.4, 0, 0.2, 1],
- },
- scale: {
- duration: 1.2,
- ease: [0.4, 0, 0.2, 1],
- },
- }
- : {
- // 開く時:幅を先に変化させてから高さを変化
- width: {
- duration: 0.5,
- ease: [0.4, 0, 0.2, 1],
- },
- height: {
- duration: 0.5,
- delay: 0.5,
- ease: [0.4, 0, 0.2, 1],
- },
- backgroundColor: {
- duration: 1.0,
- ease: [0.4, 0, 0.2, 1],
- },
- borderRadius: {
- duration: 1.0,
- ease: [0.4, 0, 0.2, 1],
- },
- scale: {
- duration: 1.0,
- ease: [0.4, 0, 0.2, 1],
- },
- }
- }
- className="relative mx-auto mb-5 flex max-w-sm flex-col overflow-hidden border border-neutral-600/30 p-4 shadow-[0_20px_60px_rgba(0,0,0,0.5)] backdrop-blur-xl sm:p-6"
- style={{
- backdropFilter: "blur(20px)",
- WebkitBackdropFilter: "blur(20px)",
- maxHeight: "80vh",
- willChange: "transform, width, height, background-color",
- transform: "translate3d(0, 0, 0)",
- backfaceVisibility: "hidden",
- WebkitFontSmoothing: "antialiased",
- }}
- >
- {/* シンプルなグロー効果 */}
-
-
- {/* ヘッダー(確認事項表示時のみ) */}
- {!showConfirmationComplete && (
-
-
- 録音前の確認
-
-
- 以下の項目をご確認ください
-
-
- )}
- {/* 確認事項リスト */}
- {!showConfirmationComplete ? (
- <>
-
-
- {/* 確認ボタン */}
-
- >
- ) : (
- <>
- {/* 確認完了画面 */}
-
-
- {/* スライドバー(確認完了後のみ表示) */}
-
-
-
- >
- )}
-
- )}
-
- )}
-
-
- {/* 録音中のUI */}
-
+
+ )}
+
+ )}
+
+
+ {/* 録音中のUI */}
+
{status !== "idle" && (
-
-
- {/* ドラッグハンドル */}
-
-
- {/* メインコンテンツ */}
-
- {/* ミニマム表示(非展開時のみ表示) */}
- {!isExpanded && (
-
- {/* 録音ボタン */}
-
{
- if (status === "recording") {
- handleStop();
- }
- }}
- className={`relative flex h-14 w-14 touch-manipulation items-center justify-center rounded-full shadow-lg transition-all duration-300 sm:h-16 sm:w-16 ${
- status === "recording"
- ? "bg-red-600 hover:bg-red-700"
- : status === "completed"
- ? "cursor-not-allowed bg-gray-400"
- : "bg-gray-600 hover:bg-gray-700"
- }
- `}
- style={{
- cursor:
- status === "completed" ? "not-allowed" : "pointer",
- }}
- whileTap={status !== "completed" ? { scale: 0.95 } : {}}
- whileHover={status !== "completed" ? { scale: 1.05 } : {}}
- disabled={status === "completed"}
- >
- {status === "recording" ? (
-
- ) : status === "completed" ? (
-
- ) : (
-
- )}
-
- {/* 録音中のパルスエフェクト */}
-
-
-
- {/* 波形表示 */}
-
-
-
-
- {/* タイマー */}
-
-
- {formatTime(recordingTime)}
-
-
- {status === "recording" ? "録音中" : "完了"}
-
-
-
- )}
-
- {/* 展開時のコンテンツ */}
-
- {isExpanded && (
-
- {/* ヘッダー部分 - 下部のpadding調整 */}
-
-
-
-
-
-
- 録音中
-
-
-
-
-
-
- {/* メインコンテンツエリア - コンテンツを上部寄りに配置 */}
-
- {/* タイマー表示 - 上部マージンを削除 */}
-
-
- {formatTime(recordingTime)}
-
-
-
- {/* 波形表示 - 余白を調整 */}
-
-
-
-
-
- {/* 一時停止ボタン - 画面下部に固定配置 */}
-
- {
- if (status === "recording") {
- handleStop();
- }
- }}
- className="relative z-50 flex h-20 w-20 touch-manipulation items-center justify-center rounded-full bg-gray-100 shadow-lg transition-all duration-300 hover:bg-gray-200 sm:h-24 sm:w-24"
- whileTap={{ scale: 0.95 }}
- >
- {/* 一時停止アイコン */}
-
-
- {/* リップルエフェクト */}
-
-
-
-
- )}
-
-
-
-
- )}
-
-
- {/* 音声再生モーダル */}
-
- {showPlayback && audioData && (
-
- )}
-
-
+ onNext={handleStop}
+ onStop={handleStop}
+ />
+ )}
+
+ )}
+
+
+ {/* 音声再生モーダル */}
+
+ {showPlayback && audioData && (
+
+ )}
+
+
);
}