@@ -5,10 +5,12 @@ import { Box, Button, Stack, Typography } from "@mui/material";
55import VideoPlayer , { IVideoHandle } from "@/components/VideoPlayer" ;
66import useMedia from "@/hooks/useMedia" ;
77import Header from "./Header" ;
8- import { useParams } from "next/navigation" ;
8+ import { useParams , useRouter } from "next/navigation" ;
99import { useTimer } from "@/hooks/useTimer" ;
10- import { notifyClassDone } from "@/utils/broadcast" ;
11- import { useToastStore } from "@/states/useToastStore" ;
10+ import { axiosClient } from "@/apis/axiosClient" ;
11+ import { isAuthError } from "@/apis/errors" ;
12+ // import { useToastStore } from "@/states/useToastStore";
13+ import SenifitDialog from "@/components/SenifitDialog" ;
1214
1315export interface IWorkoutVideo {
1416 id : number ;
@@ -68,19 +70,126 @@ export default function WorkoutVideoPlaylist({
6870 duration,
6971} : IWorkoutVideoPlaylistProps ) {
7072 const { isPhone } = useMedia ( ) ;
73+ const router = useRouter ( ) ;
7174
7275 const { seconds } = useTimer ( ) ;
7376
74- const { id : programId } = useParams ( ) ;
77+ const { id } = useParams ( ) ;
78+ const recordId = useMemo ( ( ) => ( Array . isArray ( id ) ? id [ 0 ] : id ) , [ id ] ) ;
7579
7680 const notifyDone = useCallback ( ( ) : void => {
77- const pid = Array . isArray ( programId ) ? programId [ 0 ] : programId ;
78- notifyClassDone ( { programId : pid , seconds } ) ;
79- // 브로드캐스트가 부모 탭에 전달될 시간 확보
80- window . setTimeout ( ( ) => {
81- window . close ( ) ;
82- } , 50 ) ;
83- } , [ programId , seconds ] ) ;
81+ if ( ! recordId ) return ;
82+ shouldBypassUnload . current = true ;
83+ // 단일 탭 흐름: 종료 시 완료 화면으로 이동
84+ router . replace ( `/exercise/done/${ recordId } ?seconds=${ seconds } ` ) ;
85+ } , [ recordId , router , seconds ] ) ;
86+
87+ useEffect ( ( ) => {
88+ if ( ! recordId ) return ;
89+
90+ let cancelled = false ;
91+ let timeoutId : number | null = null ;
92+ let inFlight = false ;
93+ let redirected = false ;
94+
95+ const pulse = async ( ) => {
96+ if ( cancelled || inFlight ) return ;
97+ inFlight = true ;
98+ try {
99+ await axiosClient . put ( `/records/${ recordId } ` ) ;
100+ } catch ( err ) {
101+ // axios interceptor에서 401/403 -> AuthError로 throw 되지만
102+ // 여기서 catch로 삼키면 전역 error boundary 리디렉션이 동작하지 않음.
103+ // heartbeat에서는 즉시 로그인으로 전환한다.
104+ if ( ! redirected && isAuthError ( err ) ) {
105+ redirected = true ;
106+ cancelled = true ;
107+ if ( timeoutId != null ) window . clearTimeout ( timeoutId ) ;
108+ const currentPath = window . location . pathname + window . location . search ;
109+ const loginUrl = `/login?next=${ encodeURIComponent ( currentPath ) } ` ;
110+ window . location . href = loginUrl ;
111+ return ;
112+ }
113+ console . error ( "Heartbeat failed:" , err ) ;
114+ } finally {
115+ inFlight = false ;
116+ }
117+ } ;
118+
119+ const scheduleNext = ( delayMs : number ) => {
120+ if ( cancelled ) return ;
121+ timeoutId = window . setTimeout ( async ( ) => {
122+ await pulse ( ) ;
123+ scheduleNext ( 30000 ) ;
124+ } , delayMs ) ;
125+ } ;
126+
127+ // 운동 시작 시 즉시 첫 하트비트 전송 + 이후 30초 루프
128+ void pulse ( ) ;
129+ scheduleNext ( 30000 ) ;
130+
131+ // 백그라운드 타이머 throttling 대비: 다시 포커스/표시되면 즉시 1회 전송
132+ const onVisible = ( ) => {
133+ if ( document . visibilityState === "visible" ) void pulse ( ) ;
134+ } ;
135+ window . addEventListener ( "focus" , onVisible ) ;
136+ window . addEventListener ( "visibilitychange" , onVisible ) ;
137+ window . addEventListener ( "pageshow" , onVisible ) ;
138+
139+ // 페이지가 닫히거나 전환될 때 마지막 1회 시도(keepalive)
140+ const onPageHide = ( ) => {
141+ try {
142+ void fetch ( `/api/records/${ recordId } ` , {
143+ method : "PUT" ,
144+ credentials : "include" ,
145+ keepalive : true ,
146+ } ) ;
147+ } catch {
148+ // ignore
149+ }
150+ } ;
151+ window . addEventListener ( "pagehide" , onPageHide ) ;
152+
153+ return ( ) => {
154+ cancelled = true ;
155+ if ( timeoutId != null ) window . clearTimeout ( timeoutId ) ;
156+ window . removeEventListener ( "focus" , onVisible ) ;
157+ window . removeEventListener ( "visibilitychange" , onVisible ) ;
158+ window . removeEventListener ( "pageshow" , onVisible ) ;
159+ window . removeEventListener ( "pagehide" , onPageHide ) ;
160+ } ;
161+ } , [ recordId ] ) ;
162+
163+ // 이탈 방지 bypass 플래그 (앱 내부 이동 시 사용)
164+ const shouldBypassUnload = useRef ( false ) ;
165+
166+ // 이탈 방지 로직 (브라우저 종료/새로고침)
167+ useEffect ( ( ) => {
168+ const handleBeforeUnload = ( e : BeforeUnloadEvent ) => {
169+ if ( shouldBypassUnload . current ) return ;
170+ e . preventDefault ( ) ;
171+ e . returnValue = "" ;
172+ } ;
173+
174+ window . addEventListener ( "beforeunload" , handleBeforeUnload ) ;
175+ return ( ) => window . removeEventListener ( "beforeunload" , handleBeforeUnload ) ;
176+ } , [ ] ) ;
177+
178+ // 이탈 방지 로직 (뒤로 가기)
179+ useEffect ( ( ) => {
180+ // 현재 상태를 push하여 뒤로 가기 시 popstate가 트리거되게 함
181+ window . history . pushState ( null , "" , window . location . href ) ;
182+
183+ const handlePopState = ( ) => {
184+ // 뒤로 가기 버튼을 눌렀을 때 다이얼로그를 띄움
185+ setOpenExitDialog ( true ) ;
186+ // 다시 pushState를 해서 현재 페이지를 유지 (사용자가 '나가기'를 누를 때까지)
187+ window . history . pushState ( null , "" , window . location . href ) ;
188+ } ;
189+
190+ window . addEventListener ( "popstate" , handlePopState ) ;
191+ return ( ) => window . removeEventListener ( "popstate" , handlePopState ) ;
192+ } , [ ] ) ;
84193
85194 const initialIndex = useMemo ( ( ) => {
86195 if ( initialId == null ) return 0 ;
@@ -89,6 +198,7 @@ export default function WorkoutVideoPlaylist({
89198 } , [ videos , initialId ] ) ;
90199
91200 const [ index , setIndex ] = useState < number > ( initialIndex ) ;
201+ const [ openExitDialog , setOpenExitDialog ] = useState ( false ) ;
92202 const handleRef = useRef < IVideoHandle > ( null ) ;
93203
94204 const current = videos [ index ] ;
@@ -108,7 +218,11 @@ export default function WorkoutVideoPlaylist({
108218 let target = next ;
109219
110220 if ( next >= len ) {
111- if ( ! loop ) return notifyDone ( ) ;
221+ if ( ! loop ) {
222+ // 마지막 영상까지 끝나면 자동으로 완료 처리
223+ notifyDone ( ) ;
224+ return ;
225+ }
112226 target = 0 ;
113227 } else if ( next < 0 ) {
114228 target = 0 ;
@@ -117,24 +231,24 @@ export default function WorkoutVideoPlaylist({
117231 setIndex ( target ) ;
118232 onIndexChange ?.( target , videos [ target ] ) ;
119233 } ,
120- [ videos , loop , notifyDone , onIndexChange ] ,
234+ [ videos , loop , onIndexChange , notifyDone ] ,
121235 ) ;
122- const { setToastOpen } = useToastStore ( ) ;
236+ // const { setToastOpen } = useToastStore();
123237
124238 const prev = useCallback ( ( ) => {
125- setToastOpen ( { message : "이전 영상을 재생합니다." , autoHide : "short" } ) ;
239+ // setToastOpen({ message: "이전 영상을 재생합니다.", autoHide: "short" });
126240 go ( index - 1 ) ;
127- } , [ go , index , setToastOpen ] ) ;
241+ } , [ go , index ] ) ;
128242
129243 const next = useCallback ( ( ) => {
130244 const isLast = index === videos . length - 1 ;
131245 if ( isLast && ! loop ) {
132246 notifyDone ( ) ;
133247 return ;
134248 }
135- setToastOpen ( { message : "다음 영상을 재생합니다." , autoHide : "short" } ) ;
249+ // setToastOpen({ message: "다음 영상을 재생합니다.", autoHide: "short" });
136250 go ( index + 1 ) ;
137- } , [ go , index , loop , notifyDone , setToastOpen , videos . length ] ) ;
251+ } , [ go , index , loop , notifyDone , videos . length ] ) ;
138252
139253 // src 바뀌면 자동 재생 시도(사용자 제스처 이후 연속 재생 안정화)
140254 useEffect ( ( ) => {
@@ -261,6 +375,22 @@ export default function WorkoutVideoPlaylist({
261375 </ Button >
262376 </ Stack >
263377 </ Stack >
378+
379+ { /* 이탈 확인 다이얼로그 */ }
380+ < SenifitDialog
381+ isOpen = { openExitDialog }
382+ onClose = { ( ) => setOpenExitDialog ( false ) }
383+ dialogType = { "error" }
384+ title = { "사이트에서 나가시겠습니까?" }
385+ body = { "변경사항이 저장되지 않을 수 있습니다" }
386+ primaryText = { "나가기" }
387+ onPrimaryClick = { ( ) => {
388+ shouldBypassUnload . current = true ;
389+ notifyDone ( ) ;
390+ } }
391+ secondaryText = { "취소" }
392+ onSecondaryClick = { ( ) => setOpenExitDialog ( false ) }
393+ />
264394 </ Box >
265395 ) ;
266396}
0 commit comments