diff --git a/src/entities/main/ui/DateContainer/index.tsx b/src/entities/main/ui/DateContainer/index.tsx index c6eb2ef7..4bcaaf13 100644 --- a/src/entities/main/ui/DateContainer/index.tsx +++ b/src/entities/main/ui/DateContainer/index.tsx @@ -27,7 +27,7 @@ const DateContainer = () => { }, []); const pastDays = Math.floor(totalDays / 2); - const { setSelectDate } = useSelectDateStore(); + const { selectDate, setSelectDate } = useSelectDateStore(); const { stageId } = useMyStageIdStore(); @@ -42,39 +42,37 @@ const DateContainer = () => { return { short: `${month}-${day}`, full: `${year}-${month}-${day}` }; }); + const syncSelectedDate = (fullDate: string) => { + const matchedDate = dates.find((d) => d.full === fullDate); + + if (matchedDate) { + setSelectedDate(matchedDate.short); + setSelectDate(matchedDate.full); + setStartIndex(dates.findIndex((d) => d.full === fullDate)); + } else { + const date = new Date(fullDate); + const short = `${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`; + setSelectedDate(short); + setSelectDate(fullDate); + setStartIndex(dates.findIndex((d) => d.full === fullDate)); + } + }; + useEffect(() => { - const localSelectDate = sessionStorage.getItem('selectDate'); - - if (localSelectDate) { - const { selectDate: localDate, stageId: localStageId } = - JSON.parse(localSelectDate); - - if (localDate !== '') { - if (stageId === localStageId) { - const matchedDate = dates.find((d) => d.full === localDate); - - if (matchedDate) { - setSelectedDate(matchedDate.short); - setSelectDate(matchedDate.full); - const matchedIndex = dates.findIndex((d) => d.full === localDate); - setStartIndex(matchedIndex); - } else { - const date = new Date(localDate); - const month = String(date.getMonth() + 1).padStart(2, '0'); - const day = String(date.getDate()).padStart(2, '0'); - const short = `${month}-${day}`; - - setSelectedDate(short); - setSelectDate(localDate); - const matchedIndex = dates.findIndex((d) => d.full === localDate); - setStartIndex(matchedIndex); - } - } - } + if (selectDate) syncSelectedDate(selectDate); + }, []); + + useEffect(() => { + const local = sessionStorage.getItem('selectDate'); + if (!local) return; + + const { selectDate: localDate, stageId: localStageId } = JSON.parse(local); + sessionStorage.removeItem('selectDate'); - sessionStorage.removeItem('selectDate'); + if (localDate && stageId === localStageId) { + syncSelectedDate(localDate); } - }, [dates]); + }, [dates, stageId]); const todayIndex = dates.findIndex( (d) => diff --git a/src/entities/mini-game/coin-toss/model/drawVideoToCanvas.ts b/src/entities/mini-game/coin-toss/model/drawVideoToCanvas.ts new file mode 100644 index 00000000..65359035 --- /dev/null +++ b/src/entities/mini-game/coin-toss/model/drawVideoToCanvas.ts @@ -0,0 +1,22 @@ +export function drawVideoFrameToCanvas( + video: HTMLVideoElement, + canvas: HTMLCanvasElement, + ctx: CanvasRenderingContext2D, +) { + const videoWidth = video.videoWidth; + const videoHeight = video.videoHeight; + const canvasWidth = canvas.width; + const canvasHeight = canvas.height; + + const cropWidth = videoWidth; + const cropHeight = videoHeight; + const centerX = videoWidth * 0.5; + const centerY = videoHeight * 1; + const sx = Math.max(0, centerX - cropWidth / 2); + const sy = Math.max(0, centerY - cropHeight / 2); + const sw = Math.min(cropWidth, videoWidth - sx); + const sh = Math.min(cropHeight, videoHeight - sy); + + ctx.clearRect(0, 0, canvasWidth, canvasHeight); + ctx.drawImage(video, sx, sy, sw, sh, 0, 0, canvasWidth, canvasHeight); +} diff --git a/src/entities/mini-game/coin-toss/model/useVideoCanvasPlayer.ts b/src/entities/mini-game/coin-toss/model/useVideoCanvasPlayer.ts new file mode 100644 index 00000000..640f7928 --- /dev/null +++ b/src/entities/mini-game/coin-toss/model/useVideoCanvasPlayer.ts @@ -0,0 +1,80 @@ +'use client'; + +import { useEffect, useRef } from 'react'; +import { drawVideoFrameToCanvas } from './drawVideoToCanvas'; + +interface UseVideoCanvasPlayerProps { + videoSource: string; + isPlaying: boolean; + onAnimationEnd?: () => void; +} + +export function useVideoCanvasPlayer({ + videoSource, + isPlaying, + onAnimationEnd, +}: UseVideoCanvasPlayerProps) { + const videoRef = useRef(null); + const canvasRef = useRef(null); + const animationFrameId = useRef(); + + useEffect(() => { + const video = videoRef.current; + const canvas = canvasRef.current; + if (!video || !canvas) return; + + let isMounted = true; + const ctx = canvas.getContext('2d'); + if (!ctx) return; + + const drawVideoFrames = () => { + if (!video || !canvas || !ctx) return; + if (video.readyState < 2) return; + + drawVideoFrameToCanvas(video, canvas, ctx); + + if (isMounted && isPlaying) { + animationFrameId.current = requestAnimationFrame(drawVideoFrames); + } + }; + + const drawInitialFrame = () => { + if (!video || !canvas || !ctx) return; + if (video.readyState < 2) return; + + drawVideoFrameToCanvas(video, canvas, ctx); + }; + + const handleLoadedData = () => { + if (isPlaying) { + video.play(); + drawVideoFrames(); + } else { + drawInitialFrame(); + } + }; + + const handleEnded = () => { + if (animationFrameId.current) { + cancelAnimationFrame(animationFrameId.current); + } + onAnimationEnd?.(); + }; + + video.addEventListener('loadeddata', handleLoadedData); + video.addEventListener('ended', handleEnded); + + video.src = videoSource; + video.load(); + + return () => { + isMounted = false; + video.removeEventListener('loadeddata', handleLoadedData); + video.removeEventListener('ended', handleEnded); + if (animationFrameId.current) + cancelAnimationFrame(animationFrameId.current); + }; + }, [videoSource, isPlaying, onAnimationEnd]); + + return { videoRef, canvasRef }; +} diff --git a/src/entities/mini-game/coin-toss/model/useVideoCanvasRenderer.ts b/src/entities/mini-game/coin-toss/model/useVideoCanvasRenderer.ts new file mode 100644 index 00000000..a30bc835 --- /dev/null +++ b/src/entities/mini-game/coin-toss/model/useVideoCanvasRenderer.ts @@ -0,0 +1,82 @@ +import { useEffect, RefObject, useRef } from 'react'; + +interface Args { + videoRef: RefObject; + canvasRef: RefObject; + videoSource: string; + isPlaying: boolean; + onAnimationEnd?: () => void; +} + +export function useVideoCanvasRenderer({ + videoRef, + canvasRef, + videoSource, + isPlaying, + onAnimationEnd, +}: Args) { + const frameId = useRef(); + + useEffect(() => { + const video = videoRef.current; + const canvas = canvasRef.current; + if (!video || !canvas) return; + + let mounted = true; + const ctx = canvas.getContext('2d'); + if (!ctx) return; + + const drawLoop = () => { + if (!mounted || !isPlaying) return; + if (video.readyState < 2) { + frameId.current = requestAnimationFrame(drawLoop); + return; + } + + const vw = video.videoWidth; + const vh = video.videoHeight; + const cw = canvas.width; + const ch = canvas.height; + + ctx.clearRect(0, 0, cw, ch); + ctx.drawImage(video, 0, vh - vh, vw, vh, 0, 0, cw, ch); + + frameId.current = requestAnimationFrame(drawLoop); + }; + + const handleLoadedData = () => { + if (isPlaying) { + video.play(); + drawLoop(); + } else { + if (video.readyState >= 2) { + const vw = video.videoWidth; + const vh = video.videoHeight; + const cw = canvas.width; + const ch = canvas.height; + + ctx.clearRect(0, 0, cw, ch); + ctx.drawImage(video, 0, vh - vh, vw, vh, 0, 0, cw, ch); + } + } + }; + + const handleEnded = () => { + if (frameId.current) cancelAnimationFrame(frameId.current); + onAnimationEnd?.(); + }; + + video.addEventListener('loadeddata', handleLoadedData); + video.addEventListener('ended', handleEnded); + + video.src = videoSource; + video.load(); + + return () => { + mounted = false; + video.removeEventListener('loadeddata', handleLoadedData); + video.removeEventListener('ended', handleEnded); + if (frameId.current) cancelAnimationFrame(frameId.current); + }; + }, [videoSource, isPlaying, onAnimationEnd, videoRef, canvasRef]); +} diff --git a/src/entities/mini-game/coin-toss/ui/CoinTossAnimation/index.tsx b/src/entities/mini-game/coin-toss/ui/CoinTossAnimation/index.tsx index b405c04f..74772b10 100644 --- a/src/entities/mini-game/coin-toss/ui/CoinTossAnimation/index.tsx +++ b/src/entities/mini-game/coin-toss/ui/CoinTossAnimation/index.tsx @@ -1,7 +1,8 @@ 'use client'; -import { useEffect, useRef } from 'react'; +import React from 'react'; import { cn } from '@/shared/utils/cn'; +import { useVideoCanvasPlayer } from '../../model/useVideoCanvasPlayer'; interface CoinTossAnimationProps { isPlaying: boolean; @@ -14,94 +15,11 @@ const CoinTossAnimation = ({ videoSource, onAnimationEnd, }: CoinTossAnimationProps) => { - const videoRef = useRef(null); - const canvasRef = useRef(null); - const animationFrameId = useRef(); - - useEffect(() => { - const video = videoRef.current; - const canvas = canvasRef.current; - if (!video || !canvas) return; - - const ctx = canvas.getContext('2d'); - if (!ctx) return; - - const drawToCanvas = () => { - if (!video || !canvas || !ctx) return; - - const videoWidth = video.videoWidth; - const videoHeight = video.videoHeight; - const canvasWidth = canvas.width; - const canvasHeight = canvas.height; - - const zoom = 1; - const cropWidth = videoWidth / zoom; - const cropHeight = videoHeight / zoom; - const centerX = videoWidth * 0.5; - const centerY = videoHeight * 1; - - const sx = Math.max(0, centerX - cropWidth / 2); - const sy = Math.max(0, centerY - cropHeight / 2); - const sw = Math.min(cropWidth, videoWidth - sx); - const sh = Math.min(cropHeight, videoHeight - sy); - - ctx.clearRect(0, 0, canvasWidth, canvasHeight); - ctx.drawImage(video, sx, sy, sw, sh, 0, 0, canvasWidth, canvasHeight); - - animationFrameId.current = requestAnimationFrame(drawToCanvas); - }; - - const drawInitialFrame = () => { - if (!video || !canvas || !ctx) return; - - const videoWidth = video.videoWidth; - const videoHeight = video.videoHeight; - const canvasWidth = canvas.width; - const canvasHeight = canvas.height; - - const zoom = 1; - const cropWidth = videoWidth / zoom; - const cropHeight = videoHeight / zoom; - const centerX = videoWidth * 0.5; - const centerY = videoHeight * 1; - - const sx = Math.max(0, centerX - cropWidth / 2); - const sy = Math.max(0, centerY - cropHeight / 2); - const sw = Math.min(cropWidth, videoWidth - sx); - const sh = Math.min(cropHeight, videoHeight - sy); - - ctx.clearRect(0, 0, canvasWidth, canvasHeight); - ctx.drawImage(video, sx, sy, sw, sh, 0, 0, canvasWidth, canvasHeight); - }; - - const handleEnded = () => { - if (animationFrameId.current) { - cancelAnimationFrame(animationFrameId.current); - } - onAnimationEnd?.(); - }; - - const handleLoadedData = () => { - if (isPlaying) { - video.play(); - drawToCanvas(); - } else { - drawInitialFrame(); - } - }; - - video.addEventListener('ended', handleEnded); - video.addEventListener('loadeddata', handleLoadedData); - - // ✅ 무조건 load 호출해서 첫 프레임도 준비되게 - video.load(); - - return () => { - video.removeEventListener('ended', handleEnded); - video.removeEventListener('loadeddata', handleLoadedData); - cancelAnimationFrame(animationFrameId.current!); - }; - }, [isPlaying, videoSource, onAnimationEnd]); + const { videoRef, canvasRef } = useVideoCanvasPlayer({ + videoSource, + isPlaying, + onAnimationEnd, + }); return (
@@ -120,7 +38,6 @@ const CoinTossAnimation = ({