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 && ( + + )} + +
); }