/** * 비디오 모달 관리 클래스 (VideoModalBase 활용) * 공통 모듈 활용 (ErrorHandler, DOMUtils, EventManager, Utils) */ class VideoModal extends VideoModalBase { constructor(config, markerManager, dependencies = {}) { super({ videos: [], // 학습 페이지는 videos 배열을 사용하지 않음 modalPath: config.modalPath || "./_modal/video-learning.php", modalPathTemplate: config.modalPathTemplate || "./_modal/video-{type}.php", enableHeightAdjustment: true, enableCommentResizer: false, enableCommentBox: false, }); // 의존성 주입 (폴백 포함) this.domUtils = dependencies.domUtils || (typeof DOMUtils !== 'undefined' ? DOMUtils : null); this.errorHandler = dependencies.errorHandler || (typeof ErrorHandler !== 'undefined' ? ErrorHandler : null); this.eventManager = dependencies.eventManager || (typeof eventManager !== 'undefined' ? eventManager : null); this.utils = dependencies.utils || (typeof Utils !== 'undefined' ? Utils : null); this.animationUtils = dependencies.animationUtils || (typeof AnimationUtils !== 'undefined' ? AnimationUtils : null); // 이벤트 리스너 ID 저장 (정리용) this.listenerIds = []; try { // VideoModalBase의 config와 학습 페이지 config 병합 this.config = { ...this.config, ...config }; this.markerManager = markerManager; this.currentModal = null; this.currentLearningIndex = null; this.currentChapterInfo = null; // 높이 조정 관련 (VideoModalBase의 것과 별도로 관리) this.resizeObserver = null; this.mutationObserver = null; this.heightAdjustTimer = null; this._retryCount = 0; this._isAdjustingHeight = false; // 높이 조정 중 플래그 this._windowResizeHandler = null; // window resize 핸들러 저장 // 시간 스킵 제한 관련 this.maxWatchedTime = 0; this._skipInterval = null; this._ytPlayer = null; this._skipSetupTimer = null; this._lastLearningSaveAt = 0; this._lastPlayerTime = 0; this._pendingAllTmSeconds = 0; this._lastTimingDebugAt = 0; this._activeTimingPlayerSource = ''; this._timingHasActivePlayback = false; this._bufferRecoveryAttempts = 0; this._nextBufferRecoveryAt = 0; this._lessonClickSeq = 0; this._activeLessonTraceId = null; this._lastPlaybackRateWarnAt = 0; this._setupKeyboardEvents(); } catch (error) { this._handleError(error, 'constructor'); } } /** * 에러 처리 헬퍼 * @private */ _handleError(error, context, additionalInfo = {}) { if (this.errorHandler) { this.errorHandler.handle(error, { context: `VideoModal.${context}`, component: 'VideoModal', ...additionalInfo }, false); } else { console.error(`[VideoModal] ${context}:`, error, additionalInfo); } } /** * 시간 동기화에 사용할 활성 플레이어를 반환한다. * _ytPlayer 우선, 없으면 VideoModalBase의 ytPlayer를 사용한다. * @private */ _getTimingPlayer() { const candidates = []; if (this._ytPlayer && typeof this._ytPlayer.getCurrentTime === 'function') { let t = -1; let state = null; try { t = Number(this._ytPlayer.getCurrentTime()); } catch (e) { /* ignore */ } try { state = typeof this._ytPlayer.getPlayerState === 'function' ? this._ytPlayer.getPlayerState() : null; } catch (e) { /* ignore */ } candidates.push({ source: '_ytPlayer', player: this._ytPlayer, time: Number.isFinite(t) ? t : -1, state }); } if (this.ytPlayer && typeof this.ytPlayer.getCurrentTime === 'function') { let t = -1; let state = null; try { t = Number(this.ytPlayer.getCurrentTime()); } catch (e) { /* ignore */ } try { state = typeof this.ytPlayer.getPlayerState === 'function' ? this.ytPlayer.getPlayerState() : null; } catch (e) { /* ignore */ } candidates.push({ source: 'ytPlayer', player: this.ytPlayer, time: Number.isFinite(t) ? t : -1, state }); } if (candidates.length === 0) { return null; } let chosen = candidates[0]; if (candidates.length > 1) { const playing = candidates.filter((c) => c.state === 1); if (playing.length > 0) { chosen = playing.sort((a, b) => b.time - a.time)[0]; } else { const progressed = candidates .filter((c) => c.time >= this._lastPlayerTime - 0.5) .sort((a, b) => b.time - a.time); if (progressed.length > 0) { chosen = progressed[0]; } else { chosen = candidates.sort((a, b) => b.time - a.time)[0]; } } } if (this._activeTimingPlayerSource !== chosen.source) { this._activeTimingPlayerSource = chosen.source; console.log( `[LearningTime] active timing player: ${chosen.source} (time=${Math.floor(chosen.time || 0)}s, state=${chosen.state})` ); } return chosen.player; } /** * 현재 레슨의 학습 이력을 저장한다. * @private */ async _saveCurrentLessonProgress(options = {}) { try { if ( this.currentLearningIndex === null || !this.markerManager || !this.markerManager.allMarkers ) { return null; } const lesson = this.markerManager.allMarkers[this.currentLearningIndex]; if (!lesson || lesson.isChapterMarker) { return lesson || null; } let syncedDuration = Math.max(0, Number.parseInt(lesson.content_tm ?? 0, 10) || 0); let syncedWatchTm = Math.max(0, Math.floor(this.maxWatchedTime || 0)); const timingPlayer = this._getTimingPlayer(); if (timingPlayer) { if (typeof timingPlayer.getDuration === 'function') { const playerDuration = Math.max(0, Math.floor(timingPlayer.getDuration() || 0)); syncedDuration = Math.max(syncedDuration, playerDuration); } if (typeof timingPlayer.getCurrentTime === 'function') { const playerCurrent = Math.max(0, Math.floor(timingPlayer.getCurrentTime() || 0)); syncedWatchTm = Math.max(syncedWatchTm, playerCurrent); } } if (syncedDuration > 0) { syncedWatchTm = Math.min(syncedWatchTm, syncedDuration); } lesson.content_tm = syncedDuration; lesson.watch_tm = Math.max(0, Number.parseInt(lesson.watch_tm ?? 0, 10) || 0, syncedWatchTm); this.maxWatchedTime = Math.max(this.maxWatchedTime, syncedWatchTm); if (typeof this.markerManager._saveLearningHistory === 'function') { const allTmIncrement = Math.max(0, Number.parseInt(options.all_tm_increment ?? 0, 10) || 0); await this.markerManager._saveLearningHistory(lesson, { watch_tm: syncedWatchTm, content_tm: syncedDuration, all_tm_increment: allTmIncrement, completed: options.completed === true, isWatching: options.isWatching === true, }); } return lesson; } catch (error) { this._handleError(error, '_saveCurrentLessonProgress'); return null; } } /** * 현재 레슨의 content_tm/watch_tm을 플레이어 상태와 동기화한다. * @private */ _syncPlaybackMetaFromPlayer() { try { if ( this.currentLearningIndex === null || !this.markerManager || !this.markerManager.allMarkers || !this._getTimingPlayer() ) { return; } const lesson = this.markerManager.allMarkers[this.currentLearningIndex]; if (!lesson || lesson.isChapterMarker) { return; } const timingPlayer = this._getTimingPlayer(); const playerDuration = Math.max( 0, Math.floor( (typeof timingPlayer.getDuration === 'function' ? timingPlayer.getDuration() : 0) || 0 ) ); const lessonDuration = Math.max(0, Number.parseInt(lesson.content_tm ?? 0, 10) || 0); const effectiveDuration = Math.max(playerDuration, lessonDuration); if (effectiveDuration > 0) { lesson.content_tm = effectiveDuration; } const rawResumeTm = Math.max(0, Number.parseInt(lesson.watch_tm ?? 0, 10) || 0); const resumeTm = effectiveDuration > 0 ? Math.min(rawResumeTm, Math.max(0, effectiveDuration - 1)) : rawResumeTm; this.maxWatchedTime = Math.max(this.maxWatchedTime, resumeTm); this._lastPlayerTime = this.maxWatchedTime; if (resumeTm > 0 && typeof timingPlayer.seekTo === 'function') { timingPlayer.seekTo(resumeTm, true); } } catch (error) { this._handleError(error, '_syncPlaybackMetaFromPlayer'); } } /** * 레슨 완료 조건(시청시간 100%) 충족 여부 * @private */ _isLessonCompletionSatisfied(lesson) { if (!lesson || lesson.isChapterMarker) { return false; } const contentTm = Math.max(0, Number.parseInt(lesson.content_tm ?? 0, 10) || 0); if (contentTm <= 0) { return false; } const watchedTm = Math.max(0, Math.floor(this.maxWatchedTime || 0)); return watchedTm >= contentTm; } /** * 챕터 기반으로 비디오 모달 로드 * @param {Object} chapter - 챕터 데이터 * @param {number} chapterIndex - 챕터 인덱스 * @param {number} initialLessonIndex - 초기 표시할 학습의 글로벌 인덱스 */ async loadChapter(chapter, chapterIndex, initialLessonIndex) { try { // 입력값 유효성 검증 if (!chapter || typeof chapterIndex !== 'number' || typeof initialLessonIndex !== 'number') { this._handleError(new Error('유효하지 않은 입력값'), 'loadChapter', { chapter, chapterIndex, initialLessonIndex }); return; } if (!this.markerManager || !this.markerManager.allMarkers) { this._handleError(new Error('markerManager가 없습니다.'), 'loadChapter'); return; } if (initialLessonIndex < 0 || initialLessonIndex >= this.markerManager.allMarkers.length) { this._handleError(new Error(`initialLessonIndex ${initialLessonIndex}가 범위를 벗어났습니다.`), 'loadChapter'); return; } this.destroy(); this.currentLearningIndex = initialLessonIndex; // 챕터 정보 저장 (새 구조: 챕터 마커가 첫 번째) const globalStartIndex = this.config.toGlobalIndex ? this.config.toGlobalIndex(chapterIndex, -1) : null; if (globalStartIndex === null) { this._handleError(new Error('globalStartIndex를 계산할 수 없습니다.'), 'loadChapter'); return; } this.currentChapterInfo = { chapterIndex: chapterIndex, chapterData: chapter, globalStartIndex: globalStartIndex, // 챕터 마커 인덱스 }; const modalHTML = await this._fetchModal(); const modalElement = this._parseModal(modalHTML, "learning"); if (!modalElement) { this._handleError(new Error('모달 요소를 생성할 수 없습니다.'), 'loadChapter'); return; } document.body.appendChild(modalElement); this.currentModal = modalElement; this.currentModalElement = modalElement; // VideoModalBase 호환성 const initialLesson = this.markerManager.allMarkers[initialLessonIndex]; if (!initialLesson || !initialLesson.url) { this._handleError(new Error('초기 학습 데이터가 유효하지 않습니다.'), 'loadChapter'); return; } this._setupVideo(modalElement, initialLesson.url); this._updateContent(modalElement, initialLesson, initialLessonIndex); this._show(modalElement); } catch (error) { this._handleError(error, 'loadChapter', { chapter, chapterIndex, initialLessonIndex }); if (this.errorHandler) { // ErrorHandler가 있으면 사용자 알림은 ErrorHandler가 처리 } else { alert("비디오를 로드하는 중 오류가 발생했습니다."); } } } /** * 비디오 모달 로드 (기존 호환성 유지) * @param {Object} videoData - 비디오 데이터 * @param {number} currentIndex - 현재 전역 인덱스 */ async load(videoData, currentIndex) { try { const traceId = this._activeLessonTraceId || `load-${Date.now()}`; // 입력값 유효성 검증 if (!videoData || typeof currentIndex !== 'number' || currentIndex < 0) { this._handleError(new Error('유효하지 않은 입력값'), 'load', { videoData, currentIndex }); return; } console.log( `[VideoModal] load 호출: index=${currentIndex}, label=${videoData?.label}` ); console.log(`[LearningModalTrace:${traceId}] load:start`, { currentIndex, label: videoData?.label || '', url: videoData?.url || '', currentLearningIndex: this.currentLearningIndex, hasCurrentModal: !!this.currentModal, hasYtPlayer: !!this.ytPlayer, hasCustomPlayer: !!this._ytPlayer, }); // 새로운 챕터 정보 가져오기 if (!this.config || typeof this.config.getChapterByGlobalIndex !== 'function') { this._handleError(new Error('config.getChapterByGlobalIndex가 없습니다.'), 'load'); return; } const newChapterInfo = this.config.getChapterByGlobalIndex(currentIndex); if (!newChapterInfo) { throw new Error(`챕터 정보를 찾을 수 없습니다: index=${currentIndex}`); } // globalStartIndex 계산 (getChapterByGlobalIndex에는 포함되지 않음) if (newChapterInfo.globalStartIndex === undefined && this.config.toGlobalIndex) { newChapterInfo.globalStartIndex = this.config.toGlobalIndex(newChapterInfo.chapterIndex, -1); } // 같은 챕터 내에서 학습 변경인지 확인 const isSameChapter = this.currentModal && this.currentChapterInfo && this.currentChapterInfo.chapterIndex === newChapterInfo.chapterIndex; console.log(`[LearningModalTrace:${traceId}] load:chapter-branch`, { isSameChapter, currentChapterIndex: this.currentChapterInfo?.chapterIndex, nextChapterIndex: newChapterInfo?.chapterIndex, }); if (isSameChapter) { // 같은 챕터: 비디오와 컨텐츠만 업데이트 console.log(`[VideoModal] 같은 챕터 내 학습 변경: ${videoData.label}`); const previousLearningIndex = this.currentLearningIndex; // 현재 학습 인덱스를 먼저 갱신하고 즉시 영상 전환한다. // 저장 await가 클릭 제스처 컨텍스트를 끊어 첫 재생이 막히는 케이스를 방지한다. this.currentLearningIndex = currentIndex; // 비디오와 컨텐츠만 업데이트 (목록은 유지) this._setupVideo(this.currentModal, videoData.url); this._updateContent(this.currentModal, videoData, currentIndex, false); // recreateList = false console.log(`[LearningModalTrace:${traceId}] load:same-chapter-updated`, { currentIndex, hasYtPlayerAfterSetup: !!this.ytPlayer, hasCustomPlayerAfterSetup: !!this._ytPlayer, }); // 스크롤을 현재 학습으로 이동 this._scrollToCurrentLesson(); // 이전 학습 진도/완료 처리는 백그라운드로 수행 if ( previousLearningIndex !== null && previousLearningIndex !== currentIndex ) { const previousLesson = this.markerManager.allMarkers[previousLearningIndex]; (async () => { try { if (!previousLesson || previousLesson.isChapterMarker) return; if (previousLesson.completed) { await this._saveCurrentLessonProgress({ completed: false, isWatching: false }); console.log( `[VideoModal] 이전 학습 재학습 진도 저장 완료: [${previousLearningIndex}] ${previousLesson.label}` ); return; } const isCompletedByWatch = this._isLessonCompletionSatisfied(previousLesson); if (isCompletedByWatch) { await this._saveCurrentLessonProgress({ completed: true, isWatching: false }); console.log( `[VideoModal] 이전 학습 완료 처리 완료: [${previousLearningIndex}] ${previousLesson.label}` ); this.markerManager.completeLesson(previousLearningIndex); this.currentChapterInfo = this.config.getChapterByGlobalIndex(currentIndex); if ( this.currentChapterInfo && this.currentChapterInfo.globalStartIndex === undefined && this.config.toGlobalIndex ) { this.currentChapterInfo.globalStartIndex = this.config.toGlobalIndex(this.currentChapterInfo.chapterIndex, -1); } } else { await this._saveCurrentLessonProgress({ completed: false, isWatching: false }); console.log( `[VideoModal] 이전 학습 부분 시청 저장 완료: [${previousLearningIndex}] ${previousLesson.label}` ); } } catch (bgError) { this._handleError(bgError, 'load.sameChapter.backgroundSave', { previousLearningIndex, currentIndex, }); } })(); } } else { // 다른 챕터: 모달 완전히 재생성 console.log( `[VideoModal] 챕터 변경: ${newChapterInfo.chapterData.name}` ); await this.destroy(); this.currentLearningIndex = currentIndex; this.currentChapterInfo = newChapterInfo; console.log(`[VideoModal] 챕터 정보:`, this.currentChapterInfo); const modalHTML = await this._fetchModal(); const modalElement = this._parseModal(modalHTML, videoData.type || "learning"); if (!modalElement) { this._handleError(new Error('모달 요소를 생성할 수 없습니다.'), 'load'); return; } document.body.appendChild(modalElement); this.currentModal = modalElement; this.currentModalElement = modalElement; // VideoModalBase 호환성 if (!videoData.url) { this._handleError(new Error('videoData.url이 없습니다.'), 'load'); return; } this._setupVideo(modalElement, videoData.url); this._updateContent(modalElement, videoData, currentIndex); this._show(modalElement); console.log(`[LearningModalTrace:${traceId}] load:cross-chapter-updated`, { currentIndex, hasYtPlayerAfterSetup: !!this.ytPlayer, hasCustomPlayerAfterSetup: !!this._ytPlayer, }); } console.log(`[LearningModalTrace:${traceId}] load:done`, { currentIndex, currentLearningIndex: this.currentLearningIndex, }); } catch (error) { this._handleError(error, 'load', { currentIndex, videoData, currentChapterInfo: this.currentChapterInfo, }); if (this.errorHandler) { // ErrorHandler가 있으면 사용자 알림은 ErrorHandler가 처리 } else { alert(`비디오를 로드하는 중 오류가 발생했습니다.\n${error.message}`); } } } /** * 모달 HTML 가져오기 (VideoModalBase 활용) * @private */ async _fetchModal() { // VideoModalBase의 loadModalHTML 메서드 활용 return await this.loadModalHTML("learning"); } /** * 모달 HTML 파싱 (VideoModalBase 활용) * @private */ _parseModal(modalHTML, modalType = "learning") { // VideoModalBase의 createModalFromHTML 메서드 활용 return this.createModalFromHTML(modalHTML, modalType); } /** * 비디오 설정 (VideoModalBase 활용) * @private */ _setupVideo(modalElement, videoUrl) { const traceId = this._activeLessonTraceId || `setup-${Date.now()}`; // 시간 스킵 제한 초기화 (영상 변경 시 리셋) this._resetSkipRestriction(); // VideoModalBase의 setupVideo 메서드 활용 const currentLesson = this.currentLearningIndex !== null && this.markerManager && this.markerManager.allMarkers ? this.markerManager.allMarkers[this.currentLearningIndex] : null; const resumeWatchTm = Math.max(0, Number.parseInt(currentLesson?.watch_tm ?? 0, 10) || 0); const knownContentTm = Math.max(0, Number.parseInt(currentLesson?.content_tm ?? 0, 10) || 0); const videoData = { url: videoUrl, id: videoUrl, content_id: currentLesson?.content_id || '', watch_tm: resumeWatchTm, content_tm: knownContentTm, category_code: 'LEGAL', }; const expectedVideoId = typeof this._resolveYoutubeVideoId === 'function' ? this._resolveYoutubeVideoId(videoData.url || videoData.id || '') : ''; const iframeBefore = modalElement?.querySelector('#videoFrame') || modalElement?.querySelector('iframe'); const iframeBeforeSrc = iframeBefore?.getAttribute('src') || ''; console.log(`[LearningModalTrace:${traceId}] setupVideo:before`, { contentId: videoData.content_id, url: videoData.url, resumeWatchTm, knownContentTm, iframeBeforeSrc, hasYtPlayerBefore: !!this.ytPlayer, hasCustomPlayerBefore: !!this._ytPlayer, }); // 같은 모달 내 차시 전환은 기존 플레이어를 재사용해 즉시 스위칭한다. // 플레이어 재생성 레이스로 첫 클릭 재생이 늦는 문제를 방지한다. const playerIframe = this.ytPlayer && typeof this.ytPlayer.getIframe === 'function' ? this.ytPlayer.getIframe() : null; const canDirectSwitch = !!this.ytPlayer && !!expectedVideoId && typeof this.ytPlayer.loadVideoById === 'function' && !!playerIframe && playerIframe.isConnected && !!modalElement && modalElement.contains(playerIframe); if (canDirectSwitch) { try { this.currentVideo = videoData; this.ytPlayer.loadVideoById(expectedVideoId, resumeWatchTm); if (typeof this.ytPlayer.playVideo === 'function') { this.ytPlayer.playVideo(); } this._ytPlayer = this.ytPlayer; console.log(`[LearningModalTrace:${traceId}] setupVideo:direct-switch`, { expectedVideoId, resumeWatchTm, hasYtPlayerAfter: !!this.ytPlayer, hasCustomPlayerAfter: !!this._ytPlayer, }); this._setupSkipRestriction(modalElement); return; } catch (directSwitchError) { console.warn(`[LearningModalTrace:${traceId}] setupVideo:direct-switch-failed`, directSwitchError); } } else if (this.ytPlayer && expectedVideoId) { console.log(`[LearningModalTrace:${traceId}] setupVideo:direct-switch-skipped`, { hasYtPlayer: !!this.ytPlayer, hasExpectedVideoId: !!expectedVideoId, hasPlayerIframe: !!playerIframe, iframeConnected: !!playerIframe?.isConnected, iframeInCurrentModal: !!(modalElement && playerIframe && modalElement.contains(playerIframe)), }); } this.setupVideo(modalElement, videoData); // 학습 모달은 VideoModalBase의 단일 ytPlayer를 시간 추적 소스로 사용한다. this._ytPlayer = this.ytPlayer || null; const iframeAfter = modalElement?.querySelector('#videoFrame') || modalElement?.querySelector('iframe'); const iframeAfterSrc = iframeAfter?.getAttribute('src') || ''; console.log(`[LearningModalTrace:${traceId}] setupVideo:after`, { expectedVideoId, iframeAfterSrc, hasYtPlayerAfter: !!this.ytPlayer, hasCustomPlayerAfter: !!this._ytPlayer, }); // 플레이어가 이전 영상에 머무는 케이스를 위해, 실제 video_id를 검증하고 필요 시 강제 전환 this._verifyAndForceVideoSwitch(expectedVideoId, resumeWatchTm, traceId, 1); // 시간 스킵 제한 설정 this._setupSkipRestriction(modalElement); // 첫 전환에서 플레이어 준비 타이밍 이슈가 있어도 즉시 재생 시도 setTimeout(() => { this._forcePlayCurrentVideo(traceId, expectedVideoId, resumeWatchTm); }, 120); setTimeout(() => { this._forcePlayCurrentVideo(traceId, expectedVideoId, resumeWatchTm); }, 420); } /** * 현재 영상에 대해 플레이어/API/postMessage 경로로 재생을 강제 시도한다. * @private */ _forcePlayCurrentVideo(traceId, expectedVideoId, startSeconds) { const safeStart = Math.max(0, Number.parseInt(startSeconds, 10) || 0); const player = this.ytPlayer; const iframe = (player && typeof player.getIframe === 'function' ? player.getIframe() : null) || this.currentModal?.querySelector('iframe#videoFrame, .video-area .video-box iframe, .video-box iframe, iframe'); const iframeSrc = iframe?.getAttribute('src') || ''; try { if (player && typeof player.playVideo === 'function') { if (expectedVideoId && typeof player.loadVideoById === 'function') { player.loadVideoById(expectedVideoId, safeStart); } else if (safeStart > 0 && typeof player.seekTo === 'function') { player.seekTo(safeStart, true); } player.playVideo(); } } catch (error) { console.warn(`[LearningModalTrace:${traceId}] forcePlay:player-failed`, error); } console.log(`[LearningModalTrace:${traceId}] forcePlay:attempt`, { expectedVideoId, startSeconds: safeStart, hasYtPlayer: !!player, iframeSrc, }); } /** * watch_tm 기반으로 iframe src에 시작 시점을 적용한다. * @private */ _applyResumeStartToIframe(modalElement) { try { if (this.currentLearningIndex === null || !this.markerManager || !this.markerManager.allMarkers) { return; } const lesson = this.markerManager.allMarkers[this.currentLearningIndex]; if (!lesson || lesson.isChapterMarker) { return; } const resumeTm = Math.max(0, Number.parseInt(lesson.watch_tm ?? 0, 10) || 0); if (resumeTm <= 0) { return; } const iframe = modalElement?.querySelector('#videoFrame') || modalElement?.querySelector('iframe'); if (!iframe) { return; } const src = iframe.getAttribute('src') || ''; if (!src || src === 'about:blank') { return; } let nextSrc = src; if (/([?&])start=\d+/i.test(nextSrc)) { nextSrc = nextSrc.replace(/([?&])start=\d+/i, `$1start=${resumeTm}`); } else { const sep = nextSrc.includes('?') ? '&' : '?'; nextSrc = `${nextSrc}${sep}start=${resumeTm}`; } if (nextSrc !== src) { iframe.setAttribute('src', nextSrc); } this.maxWatchedTime = Math.max(this.maxWatchedTime, resumeTm); this._lastPlayerTime = this.maxWatchedTime; } catch (error) { this._handleError(error, '_applyResumeStartToIframe'); } } /** * 시간 스킵 제한 초기화 (영상 변경 시 호출) * @private */ _resetSkipRestriction() { clearInterval(this._skipInterval); clearTimeout(this._skipSetupTimer); this._skipInterval = null; this._skipSetupTimer = null; this.maxWatchedTime = 0; this._lastLearningSaveAt = 0; this._lastPlayerTime = 0; this._pendingAllTmSeconds = 0; this._lastTimingDebugAt = 0; this._activeTimingPlayerSource = ''; this._timingHasActivePlayback = false; this._bufferRecoveryAttempts = 0; this._nextBufferRecoveryAt = 0; this._lastPlaybackRateWarnAt = 0; // destroy() 금지 - 모달 내 iframe DOM element 파괴 방지 this._ytPlayer = null; } /** * YouTube 시간 스킵 제한 설정 * 앞으로 이동(스킵) 불가, 이미 시청한 구간까지만 재탐색 허용 * @private */ _setupSkipRestriction(modalElement) { const traceId = this._activeLessonTraceId || `skip-${Date.now()}`; // 별도 YT.Player를 다시 만들지 않고, VideoModalBase의 ytPlayer 준비를 대기한다. clearTimeout(this._skipSetupTimer); console.log(`[LearningModalTrace:${traceId}] skipRestriction:start`, { hasYtPlayer: !!this.ytPlayer, hasCustomPlayer: !!this._ytPlayer, }); const bindBasePlayer = () => { if (this.ytPlayer && typeof this.ytPlayer.getCurrentTime === 'function') { this._ytPlayer = this.ytPlayer; this._enforcePlaybackRateOneX(this._ytPlayer, 'bindBasePlayer'); this._syncPlaybackMetaFromPlayer(); this._startSkipInterval(); console.log(`[LearningModalTrace:${traceId}] skipRestriction:bound`, { hasYtPlayer: !!this.ytPlayer, hasCustomPlayer: !!this._ytPlayer, }); return; } this._skipSetupTimer = setTimeout(bindBasePlayer, 300); }; bindBasePlayer(); } /** * 법정교육 재생 중에는 1배속을 강제한다. * @private */ _enforcePlaybackRateOneX(player, source = '') { if (!player) { return; } if (typeof player.getPlaybackRate !== 'function' || typeof player.setPlaybackRate !== 'function') { return; } try { const rate = Number(player.getPlaybackRate()); if (!Number.isFinite(rate) || rate === 1) { return; } player.setPlaybackRate(1); const now = Date.now(); if (this._lastPlaybackRateWarnAt === 0 || now - this._lastPlaybackRateWarnAt >= 5000) { console.warn(`[LearningModal] 배속 변경 감지(${rate}x) -> 1x로 원복 (${source || 'unknown'})`); this._lastPlaybackRateWarnAt = now; } } catch (error) { this._handleError(error, '_enforcePlaybackRateOneX', { source }); } } /** * setupVideo 이후 실제 플레이어 video_id를 검증하고 불일치 시 강제 전환한다. * @private */ _verifyAndForceVideoSwitch(expectedVideoId, startSeconds, traceId, attempt) { if (!expectedVideoId) return; const maxAttempts = 4; const safeAttempt = Math.max(1, Number.parseInt(attempt, 10) || 1); const player = this.ytPlayer; const iframe = (player && typeof player.getIframe === 'function' ? player.getIframe() : null) || this.currentModal?.querySelector('iframe#videoFrame, .video-area .video-box iframe, .video-box iframe, iframe'); const iframeSrc = iframe?.getAttribute('src') || ''; let actualVideoId = ''; try { if (player && typeof player.getVideoData === 'function') { actualVideoId = String(player.getVideoData()?.video_id || ''); } } catch (error) { actualVideoId = ''; } console.log(`[LearningModalTrace:${traceId}] verifySwitch:check`, { attempt: safeAttempt, expectedVideoId, actualVideoId, hasYtPlayer: !!player, iframeSrc, }); // iframe은 이미 기대 영상을 가리키는데 player 메타만 비어있는 경우, 재생만 강제한다. if (!actualVideoId && iframeSrc.indexOf(`/embed/${expectedVideoId}`) !== -1) { this._forcePlayCurrentVideo(traceId, expectedVideoId, startSeconds); } if (actualVideoId === expectedVideoId) { return; } const canForceByApi = !!player && typeof player.loadVideoById === 'function'; if (!canForceByApi) { if (iframe) { const targetSrc = `https://www.youtube.com/embed/${expectedVideoId}?autoplay=1&controls=1&rel=0&modestbranding=1&enablejsapi=1&start=${Math.max(0, Number.parseInt(startSeconds, 10) || 0)}`; if (iframe.getAttribute('src') !== targetSrc) { iframe.setAttribute('src', targetSrc); console.warn(`[LearningModalTrace:${traceId}] verifySwitch:forced-iframe-src`, { expectedVideoId, startSeconds: Math.max(0, Number.parseInt(startSeconds, 10) || 0), targetSrc, }); } } else { console.warn(`[LearningModalTrace:${traceId}] verifySwitch:no-iframe-no-api`, { hasYtPlayer: !!player, hasLoadVideoById: !!(player && typeof player.loadVideoById === 'function'), }); } if (safeAttempt < maxAttempts) { setTimeout(() => { this._verifyAndForceVideoSwitch(expectedVideoId, startSeconds, traceId, safeAttempt + 1); }, 250); } return; } try { if (typeof player.loadVideoById === 'function') { player.loadVideoById( expectedVideoId, Math.max(0, Number.parseInt(startSeconds, 10) || 0) ); } if (typeof player.playVideo === 'function') { player.playVideo(); } console.warn(`[LearningModalTrace:${traceId}] verifySwitch:forced-loadVideoById`, { expectedVideoId, startSeconds: Math.max(0, Number.parseInt(startSeconds, 10) || 0), }); } catch (error) { console.error(`[LearningModalTrace:${traceId}] verifySwitch:force-failed`, error); } if (safeAttempt < maxAttempts) { setTimeout(() => { this._verifyAndForceVideoSwitch(expectedVideoId, startSeconds, traceId, safeAttempt + 1); }, 250); } } /** * YT.Player 초기화 (iframe 리로드 완료 후 호출) * @private */ _initYTPlayer(iframe) { // Deprecated: learning modal uses VideoModalBase.ytPlayer only. this._ytPlayer = this.ytPlayer || null; } /** * 모달 컨텐츠 업데이트 * @private */ _updateContent(modalElement, videoData, currentIndex, recreateList = true) { this._updateTitle(modalElement, videoData.label); this._updateDescription(modalElement, videoData.description); this._updateProgress(modalElement, currentIndex); if (recreateList) { // 새 챕터: 목록 재생성 this._createLearningList(modalElement, currentIndex); } else { // 같은 챕터: 활성 상태만 업데이트 this._updateLearningListState(modalElement, currentIndex); } } /** * 영상 설명 업데이트 (edu_contents.description 연동) * @private */ _updateDescription(modalElement, description) { try { const descEl = this.domUtils?.$(".video-info .desc", modalElement) || modalElement.querySelector(".video-info .desc"); if (descEl && typeof description === 'string') { descEl.textContent = description; } } catch (error) { this._handleError(error, '_updateDescription'); } } /** * 제목 업데이트 * @private */ _updateTitle(modalElement, label) { try { if (!modalElement || !label) { console.warn("[VideoModal] _updateTitle: 유효하지 않은 입력값"); return; } const videoTitle = this.domUtils?.$(".tit-box h3", modalElement) || modalElement.querySelector(".tit-box h3"); const currentLesson = this.domUtils?.$(".sub-txt", modalElement) || modalElement.querySelector(".sub-txt"); if (videoTitle) videoTitle.textContent = label; if (currentLesson) currentLesson.textContent = label; } catch (error) { this._handleError(error, '_updateTitle', { label }); } } /** * 진행률 업데이트 (현재 챕터 기준) * @private */ _updateProgress(modalElement, currentIndex) { try { if (!this.currentChapterInfo) { console.warn("[VideoModal] currentChapterInfo가 없습니다"); return; } if (!modalElement) { console.warn("[VideoModal] modalElement가 없습니다"); return; } const currentChapter = this.currentChapterInfo.chapterData; const lessons = currentChapter?.lessons; if (!lessons || lessons.length === 0) { console.warn("[VideoModal] lessons가 비어있습니다"); return; } // 현재 챕터의 완료된 학습 개수 const completedCount = lessons.filter((lesson) => lesson && lesson.completed === true).length; const totalCount = lessons.length; const progressPercent = Math.round((completedCount / totalCount) * 100); // 현재 학습의 챕터 내 로컬 인덱스 계산 const globalStartIndex = this.currentChapterInfo.globalStartIndex; const hasChapterMarkerP = this.markerManager?.allMarkers[globalStartIndex]?.isChapterMarker === true; const lessonOffsetP = hasChapterMarkerP ? 1 : 0; const localIndex = currentIndex - globalStartIndex - lessonOffsetP; // 챕터 마커 유무에 따라 오프셋 결정 console.log(`[VideoModal] 인덱스 계산:`, { currentIndex, globalStartIndex, localIndex, lessonLabel: this.currentChapterInfo.lessonData?.label, }); const gaugeFill = this.domUtils?.$("#gaugeFill", modalElement) || modalElement.querySelector("#gaugeFill"); const labelElement = this.domUtils?.$(".gauge-labels .label:not(.current)", modalElement) || modalElement.querySelector( ".gauge-labels .label:not(.current)" ); const progressText = this.domUtils?.$(".gauge-labels .label.current em", modalElement) || modalElement.querySelector( ".gauge-labels .label.current em" ); // 게이지바: 챕터 진행률 if (gaugeFill) { if (this.domUtils) { this.domUtils.setStyles(gaugeFill, { width: `${progressPercent}%` }); } else { gaugeFill.style.width = progressPercent + "%"; } } // 차시 업데이트: "현재차시 / 총차시" 형식 if (labelElement) { const currentText = localIndex + 1; labelElement.innerHTML = `${currentText} / ${totalCount} 강`; console.log(`[VideoModal] 차시 업데이트: ${currentText} / ${totalCount}`); } else { console.warn("[VideoModal] 차시 표시 요소를 찾을 수 없습니다."); } // 진행률 퍼센트: 챕터 진행률 if (progressText) progressText.textContent = progressPercent; console.log( `[VideoModal] 챕터 진행률: ${completedCount}/${totalCount} (${progressPercent}%), 현재 차시: ${localIndex + 1}` ); } catch (error) { this._handleError(error, '_updateProgress', { currentIndex }); } } /** * 학습 목차 생성 (현재 챕터만) * @private */ _createLearningList(modalElement, currentGlobalIndex) { const list = modalElement.querySelector(".learning-list"); if (!list) return; list.innerHTML = ""; if (!this.currentChapterInfo) { console.error("[VideoModal] currentChapterInfo가 없습니다"); return; } // 현재 챕터 정보 const currentChapter = this.currentChapterInfo.chapterData; const globalStartIndex = this.currentChapterInfo.globalStartIndex; console.log("[VideoModal] 목차 생성:", { chapterName: currentChapter.name, globalStartIndex, lessonsCount: currentChapter.lessons?.length, }); if (globalStartIndex === undefined || globalStartIndex === null) { console.error( "[VideoModal] globalStartIndex가 유효하지 않습니다:", globalStartIndex ); return; } // 챕터는 시작점 표시용이므로 목차에서 제외 // 하위 lessons만 추가 (실제 강의) const currentChapterLessons = currentChapter.lessons; if (!currentChapterLessons || currentChapterLessons.length === 0) { console.warn("[VideoModal] lessons가 비어있습니다"); return; } // allMarkers[globalStartIndex]가 챕터 마커이면 +1 오프셋, 없으면 +0 const hasChapterMarker = this.markerManager.allMarkers[globalStartIndex]?.isChapterMarker === true; const lessonStartOffset = hasChapterMarker ? 1 : 0; currentChapterLessons.forEach((lesson, localIndex) => { const globalIndex = globalStartIndex + lessonStartOffset + localIndex; console.log( `[VideoModal] 목차 항목 생성: ${lesson.label}, globalIndex=${globalIndex}, localIndex=${localIndex}, hasChapterMarker=${hasChapterMarker}` ); const li = this._createListItem( lesson, globalIndex, localIndex, currentGlobalIndex ); list.appendChild(li); }); } /** * 학습 목차 상태만 업데이트 (목록 재생성 없이) * @private */ _updateLearningListState(modalElement, currentGlobalIndex) { const list = modalElement.querySelector(".learning-list"); if (!list) return; console.log( `[VideoModal] 목차 상태 업데이트: currentIndex=${currentGlobalIndex}` ); // 모든 항목의 상태 업데이트 const listItems = list.querySelectorAll("li"); listItems.forEach((li) => { const link = li.querySelector(".list"); if (!link) return; const globalIndex = parseInt(link.dataset.globalIndex); // allMarkers에서 최신 completed 상태 가져오기 const lessonMarker = this.markerManager.allMarkers[globalIndex]; if (!lessonMarker) { console.warn( `[VideoModal] 마커를 찾을 수 없음: globalIndex=${globalIndex}` ); return; } const isCurrentLesson = globalIndex === currentGlobalIndex; const isRelearning = isCurrentLesson && lessonMarker.completed; console.log( `[VideoModal] 항목 상태 업데이트: [${globalIndex}] ${lessonMarker.label}, completed=${lessonMarker.completed}, isCurrentLesson=${isCurrentLesson}` ); // 클래스 업데이트 li.className = ""; // 초기화 if (lessonMarker.completed) { li.className = "complet"; // 완료된 학습은 재학습 중이어도 완료 상태 유지 } else if (isCurrentLesson) { li.className = "active"; } // data-current 속성 업데이트 if (isCurrentLesson) { li.setAttribute("data-current", "true"); } else { li.removeAttribute("data-current"); } // 상태 텍스트 업데이트 const stateElement = li.querySelector(".state"); if (stateElement) { stateElement.textContent = this._getStateText( lessonMarker, isCurrentLesson, isRelearning ); } }); } /** * 목차 항목 생성 * @private */ _createListItem(lesson, globalIndex, localIndex, currentGlobalIndex) { const li = document.createElement("li"); // globalIndex 유효성 검사 if ( globalIndex === undefined || globalIndex === null || isNaN(globalIndex) ) { console.error("[VideoModal] 유효하지 않은 globalIndex:", globalIndex); globalIndex = 0; // 폴백 } const isCurrentLesson = globalIndex === currentGlobalIndex; const isRelearning = isCurrentLesson && lesson.completed; // 재학습 여부 // 클래스 설정 (완료된 학습은 재학습 중이어도 complet 유지) if (lesson.completed) { li.className = "complet"; } else if (isCurrentLesson) { // 처음 학습 중 li.className = "active"; } // 모든 항목 클릭 가능 li.style.cursor = "pointer"; // 현재 학습 중인 항목에 data 속성 추가 if (isCurrentLesson) { li.setAttribute("data-current", "true"); } // YouTube 썸네일 생성: lesson.url에서 YouTube ID 추출 const ytId = String(lesson.url || '').trim(); const thumbSrc = ytId && /^[a-zA-Z0-9_-]{11}$/.test(ytId) ? `https://img.youtube.com/vi/${ytId}/mqdefault.jpg` : `/img/video/img_learning_thumb_0${(globalIndex % 6) + 1}.png`; // HTML 생성 li.innerHTML = ` ${localIndex + 1}차시
${lesson.label}
${this._getStateText(lesson, isCurrentLesson, isRelearning)}
`; // 이벤트 설정 (항상 클릭 가능) this._setupListItemEvent(li, globalIndex); return li; } /** * 상태 텍스트 반환 * @private */ _getStateText(lesson, isCurrentLesson, isRelearning) { if (lesson.completed) return "학습완료"; // 완료된 학습은 재학습 중이어도 완료 표시 if (isCurrentLesson) return "학습중"; return "미진행"; } /** * 목차 항목 이벤트 설정 * @private */ _setupListItemEvent(li, globalIndex) { const link = li.querySelector(".list"); link.addEventListener("click", async (e) => { e.preventDefault(); // 모달에서는 모든 항목 클릭 가능 const clickedIndex = parseInt(e.currentTarget.dataset.globalIndex); const allMarkers = this.markerManager.allMarkers; this._lessonClickSeq += 1; this._activeLessonTraceId = `lesson-click-${Date.now()}-${this._lessonClickSeq}`; const traceId = this._activeLessonTraceId; const clickedLesson = allMarkers[clickedIndex]; console.log(`[LearningModalTrace:${traceId}] list:click`, { clickSeq: this._lessonClickSeq, clickedIndex, currentLearningIndex: this.currentLearningIndex, clickedLabel: clickedLesson?.label || '', clickedContentId: clickedLesson?.content_id || '', hasCurrentModal: !!this.currentModal, hasYtPlayer: !!this.ytPlayer, hasCustomPlayer: !!this._ytPlayer, }); await this.load(allMarkers[clickedIndex], clickedIndex); console.log(`[LearningModalTrace:${traceId}] list:click-finished`, { clickSeq: this._lessonClickSeq, currentLearningIndex: this.currentLearningIndex, hasYtPlayer: !!this.ytPlayer, hasCustomPlayer: !!this._ytPlayer, }); }); } /** * 모달 표시 * @private */ _show(modalElement) { // 모달을 먼저 숨긴 상태로 표시 (높이 조정이 보이지 않도록) modalElement.style.display = "block"; modalElement.style.visibility = "hidden"; modalElement.style.opacity = "0"; setTimeout(() => { this._setupCloseEvents(modalElement); // 높이 조정 완료 후 모달 표시 this._initializeHeightAdjustment(() => { // 높이 조정 완료 후 모달을 보이게 함 modalElement.style.visibility = "visible"; modalElement.style.opacity = "1"; modalElement.style.transition = "opacity 0.3s ease"; this._scrollToCurrentLesson(); // 현재 학습으로 스크롤 }); }, 50); } // ============================================ // 스크롤 관리 // ============================================ /** * 현재 학습 중인 항목으로 스크롤 * @private */ _scrollToCurrentLesson() { const learningList = this.currentModal?.querySelector(".learning-list"); if (!learningList) { console.warn("학습 목록을 찾을 수 없습니다"); return; } // 현재 학습 중인 항목 찾기 const currentItem = learningList.querySelector('li[data-current="true"]'); if (!currentItem) { console.warn("현재 학습 항목을 찾을 수 없습니다"); return; } // 스크롤 위치 계산 const listRect = learningList.getBoundingClientRect(); const itemRect = currentItem.getBoundingClientRect(); // 목록 중앙에 현재 항목이 오도록 스크롤 const scrollOffset = itemRect.top - listRect.top - listRect.height / 2 + itemRect.height / 2; // 부드러운 스크롤 learningList.scrollTo({ top: learningList.scrollTop + scrollOffset, behavior: "smooth", }); console.log(`[VideoModal] 현재 학습으로 스크롤 이동:`, { currentItem: currentItem.querySelector(".title")?.textContent, scrollOffset, }); // 시각적 강조 효과 (선택사항) this._highlightCurrentLesson(currentItem); } /** * 현재 학습 항목 시각적 강조 * @private */ _highlightCurrentLesson(currentItem) { // 이미 active 클래스가 있으므로 추가 효과는 선택사항 // 예: 깜빡임 효과, 테두리 강조 등 currentItem.style.transition = "all 0.3s ease"; // 간단한 강조 효과 (선택사항) setTimeout(() => { currentItem.style.transition = ""; }, 1000); } // ============================================ // 높이 조정 // ============================================ /** * 높이 조정 초기화 * @private * @param {Function} onComplete - 높이 조정 완료 후 실행할 콜백 */ _initializeHeightAdjustment(onComplete) { // 1단계: 즉시 시도 this._adjustModalContentHeight(); this._adjustLearningListHeight(); // 2단계: 다음 프레임에서 시도 requestAnimationFrame(() => { this._adjustModalContentHeight(); this._adjustLearningListHeight(); }); // 3단계: 50ms 후 시도 setTimeout(() => { this._adjustModalContentHeight(); this._adjustLearningListHeight(); }, 50); // 4단계: 100ms 후 시도 setTimeout(() => { this._adjustModalContentHeight(); this._adjustLearningListHeight(); }, 100); // 5단계: 200ms 후 시도 (높이 조정 완료로 간주) setTimeout(() => { this._adjustModalContentHeight(); this._adjustLearningListHeight(); // 높이 조정 완료 콜백 실행 if (onComplete && typeof onComplete === 'function') { onComplete(); } }, 200); // 6단계: ResizeObserver 설정 this._setupResizeObserver(); // 7단계: MutationObserver 설정 this._setupMutationObserver(); // 8단계: 이미지 로딩 대기 (이미지 로딩 후에도 높이 재조정) this._waitForImagesAndAdjust(); } /** * 모달 컨텐츠 높이 조정 * 높이가 화면 높이의 80% 이상일 때만 조절하고, 아닐 경우 80%로 유지 * @private */ _adjustModalContentHeight() { // 모바일에서는 modal-content 인라인 스타일 조정 안 함 (video-area 상단 고정 유지) const isMobile = window.matchMedia('(max-width: 767px)').matches; if (isMobile) return; const modalContent = this.currentModal?.querySelector(".modal-content"); if (!modalContent) { console.warn("modal-content 요소를 찾을 수 없습니다"); return; } // 화면 높이의 80% 계산 const viewportHeight = window.innerHeight; const maxHeight = viewportHeight * 0.8; // 현재 모달 컨텐츠의 실제 높이 (스타일이 적용되기 전의 자연스러운 높이) // 높이 스타일을 임시로 제거하여 실제 컨텐츠 높이 측정 const originalHeight = modalContent.style.height; modalContent.style.height = "auto"; const actualHeight = modalContent.scrollHeight; modalContent.style.height = originalHeight; // 실제 높이가 80% 이상이면 조절하지 않고 그대로 유지, 그렇지 않으면 80%로 설정 if (actualHeight >= maxHeight) { // 80% 이상이면 높이를 조절하지 않음 (스타일 제거하여 자연스러운 높이 유지) modalContent.style.height = "auto"; modalContent.style.overflowY = "auto"; } else { // 80% 미만이면 80%로 설정 modalContent.style.height = maxHeight + "px"; modalContent.style.overflowY = "auto"; } console.log("모달 컨텐츠 높이 조정:", { viewportHeight, maxHeight, actualHeight, finalHeight: modalContent.style.height, }); } /** * 학습 목록 높이 조정 * @private */ _adjustLearningListHeight() { if (!this.currentModal || !this.currentModal.isConnected) { this._isAdjustingHeight = false; return; } const modalStyle = window.getComputedStyle(this.currentModal); if (modalStyle.display === 'none') { this._isAdjustingHeight = false; return; } const videoSide = this.currentModal?.querySelector(".video-side"); const videoHeader = this.currentModal?.querySelector(".video-header"); const videoList = this.currentModal?.querySelector(".video-list"); const learningList = this.currentModal?.querySelector(".learning-list"); const gaugeWrap = this.currentModal?.querySelector(".gauge-wrap"); const commentWrap = this.currentModal?.querySelector(".comment-wrap"); if (!videoSide || !videoHeader || !learningList) { console.warn("필요한 요소를 찾을 수 없습니다"); this._isAdjustingHeight = false; // 실패 시 플래그 해제 return; } // 높이 조정 중 플래그 설정 this._isAdjustingHeight = true; // 스크롤 위치 저장 const savedScrollTop = learningList.scrollTop; // 전체 높이 const totalHeight = videoSide.clientHeight; // 높이가 0이거나 비정상적으로 작으면 DOM이 아직 렌더링되지 않은 것 if (totalHeight < 100) { if (modalStyle.visibility === 'hidden') { this._isAdjustingHeight = false; requestAnimationFrame(() => this._adjustLearningListHeight()); return; } console.warn( `videoSide 높이가 비정상적으로 작습니다: ${totalHeight}px. 재측정 예약...` ); // 최대 3번까지만 재시도 if (this._retryCount < 3) { this._retryCount++; this._isAdjustingHeight = false; // 재시도 전 플래그 해제 setTimeout(() => this._adjustLearningListHeight(), 100); } else { console.error("높이 측정 재시도 횟수 초과"); this._retryCount = 0; this._isAdjustingHeight = false; // 실패 시 플래그 해제 } return; } // 재시도 카운터 초기화 this._retryCount = 0; // 헤더 높이 (gaugeHeight는 headerHeight에 이미 포함되어 있음) const headerHeight = videoHeader.offsetHeight; // learning-list에 사용 가능한 최대 높이 계산 // video-list가 있으면 제목과 padding 고려 let titleHeight = 0; let paddingTop = 0; let paddingBottom = 0; if (videoList) { // video-list의 제목 높이 const videoListTitle = videoList.querySelector("h5.tit"); titleHeight = videoListTitle ? videoListTitle.offsetHeight : 0; // video-list의 padding 값 계산 const videoListStyle = window.getComputedStyle(videoList); paddingTop = parseInt(videoListStyle.paddingTop) || 0; paddingBottom = parseInt(videoListStyle.paddingBottom) || 0; } // comment-wrap 높이 const commentWrapHeight = commentWrap && commentWrap.offsetHeight > 0 ? commentWrap.offsetHeight : 0; // learning-list에 사용 가능한 최대 높이 // learning-list는 video-list 내부에 있으므로 video-list의 제목과 padding을 고려해야 함 const availableHeight = totalHeight - headerHeight - commentWrapHeight - titleHeight - paddingTop - paddingBottom - 10; // 사용 가능한 높이가 음수이거나 너무 작으면 경고 if (availableHeight < 50) { console.warn(`사용 가능한 높이가 너무 작습니다: ${availableHeight}px`); this._isAdjustingHeight = false; // 실패 시 플래그 해제 return; } // 현재 설정된 높이 확인 const currentHeight = learningList.style.height ? parseInt(learningList.style.height) : learningList.offsetHeight; // 리스트의 실제 컨텐츠 높이 (스타일 제거 후 측정) const originalHeight = learningList.style.height; learningList.style.height = "auto"; const listContentHeight = learningList.scrollHeight; learningList.style.height = originalHeight; // 컨텐츠가 적으면 컨텐츠 높이만큼, 많으면 사용 가능한 높이만큼 const listHeight = Math.min(listContentHeight, availableHeight); // 최소 높이 보장 const finalHeight = Math.max(listHeight, 100); // 컨텐츠 높이를 CSS 변수로 설정 (::before 요소에서 사용) learningList.style.setProperty("--scroll-height", listContentHeight + "px"); // learning-list의 height와 overflow-y는 CSS로 관리 (인라인 스타일 제거) // video-list가 있는 경우 높이 계산 및 스크롤 설정 if (videoList) { // video-list의 제목 높이 const videoListTitle = videoList.querySelector("h5.tit"); const videoListTitleHeight = videoListTitle ? videoListTitle.offsetHeight : 0; // video-list의 padding 값 계산 const videoListStyle = window.getComputedStyle(videoList); const videoListPaddingTop = parseInt(videoListStyle.paddingTop) || 0; const videoListPaddingBottom = parseInt(videoListStyle.paddingBottom) || 0; // video-list에 사용 가능한 높이 계산 // video-list는 learning-list를 포함하므로, 전체 높이에서 헤더, 댓글, 제목, padding을 제외 // gaugeHeight는 headerHeight에 이미 포함되어 있으므로 별도로 빼지 않음 const commentWrapHeight = commentWrap && commentWrap.offsetHeight > 0 ? commentWrap.offsetHeight : 0; const videoListAvailableHeight = totalHeight - headerHeight - commentWrapHeight - 10; // video-list의 실제 컨텐츠 높이 측정 (learning-list 포함) const videoListOriginalHeight = videoList.style.height; const videoListOriginalOverflow = videoList.style.overflowY; videoList.style.height = "auto"; videoList.style.overflowY = "visible"; const videoListContentHeight = videoList.scrollHeight; videoList.style.height = videoListOriginalHeight; videoList.style.overflowY = videoListOriginalOverflow; // video-list의 높이 계산 const videoListHeight = Math.min(videoListContentHeight, videoListAvailableHeight); const videoListFinalHeight = Math.max(videoListHeight, 100); // video-list의 현재 높이 확인 const videoListCurrentHeight = videoList.style.height ? parseInt(videoList.style.height) : videoList.offsetHeight; const videoListHeightChanged = Math.abs(videoListCurrentHeight - videoListFinalHeight) > 1; const videoListNeedsScroll = videoListContentHeight > videoListFinalHeight; // video-list 높이 및 스크롤 설정 requestAnimationFrame(() => { if (videoListHeightChanged) { videoList.style.height = videoListFinalHeight + "px"; } if (videoListNeedsScroll) { videoList.style.overflowY = "auto"; } else { videoList.style.overflowY = ""; // CSS 기본값 사용 } }); } // 스크롤 위치 복원 (높이 변경 여부와 관계없이) requestAnimationFrame(() => { learningList.scrollTop = savedScrollTop; // 높이 조정 완료 후 플래그 해제 this._isAdjustingHeight = false; }); console.log("높이 측정 성공:", { totalHeight, headerHeight, availableHeight, listContentHeight, listHeight, finalHeight, currentHeight, savedScrollTop, }); } /** * ResizeObserver 설정 * @private */ _setupResizeObserver() { const videoSide = this.currentModal?.querySelector(".video-side"); const gaugeWrap = this.currentModal?.querySelector(".gauge-wrap"); const modalContent = this.currentModal?.querySelector(".modal-content"); if (!videoSide) return; // 기존 observer 정리 if (this.resizeObserver) { this.resizeObserver.disconnect(); } this.resizeObserver = new ResizeObserver((entries) => { // 디바운스 처리 clearTimeout(this.heightAdjustTimer); this.heightAdjustTimer = setTimeout(() => { console.log("ResizeObserver 감지: 높이 재조정"); this._adjustModalContentHeight(); this._adjustLearningListHeight(); }, 50); }); this.resizeObserver.observe(videoSide); if (gaugeWrap) { this.resizeObserver.observe(gaugeWrap); } if (modalContent) { this.resizeObserver.observe(modalContent); } // window resize 이벤트도 감지 this._windowResizeHandler = () => { try { clearTimeout(this.heightAdjustTimer); // Utils.throttle 사용 (있는 경우) const adjustHeight = () => { console.log("Window resize 감지: 높이 재조정"); this._adjustModalContentHeight(); this._adjustLearningListHeight(); }; if (this.utils && this.utils.throttle) { const throttledAdjust = this.utils.throttle(adjustHeight, 50); this.heightAdjustTimer = setTimeout(throttledAdjust, 50); } else { this.heightAdjustTimer = setTimeout(adjustHeight, 50); } } catch (error) { this._handleError(error, '_setupResizeObserver.windowResizeHandler'); } }; if (this.eventManager) { const listenerId = this.eventManager.on(window, "resize", this._windowResizeHandler); this.listenerIds.push({ element: window, id: listenerId, type: 'resize' }); } else { window.addEventListener("resize", this._windowResizeHandler); } } /** * MutationObserver 설정 * @private */ _setupMutationObserver() { const learningList = this.currentModal?.querySelector(".learning-list"); if (!learningList) return; // 기존 observer 정리 if (this.mutationObserver) { this.mutationObserver.disconnect(); } this.mutationObserver = new MutationObserver((mutations) => { // 높이 조정 중이면 무시 (무한 루프 방지) if (this._isAdjustingHeight) { return; } // learning-list의 style 속성 변경은 무시 (우리가 조정한 것) const hasRelevantChange = mutations.some((mutation) => { // learning-list 자체의 style 변경은 무시 if ( mutation.type === "attributes" && mutation.attributeName === "style" && mutation.target === learningList ) { return false; } // childList 변경이나 다른 요소의 변경만 감지 return mutation.type === "childList" || (mutation.type === "attributes" && mutation.target !== learningList); }); if (!hasRelevantChange) { return; } // 디바운스 처리 clearTimeout(this.heightAdjustTimer); this.heightAdjustTimer = setTimeout(() => { console.log("MutationObserver 감지: 높이 재조정"); this._adjustModalContentHeight(); this._adjustLearningListHeight(); }, 50); }); this.mutationObserver.observe(learningList, { childList: true, subtree: true, attributes: true, attributeFilter: ["style", "class"], }); } /** * 이미지 로딩 대기 후 높이 조정 * @private */ async _waitForImagesAndAdjust() { const learningList = this.currentModal?.querySelector(".learning-list"); if (!learningList) return; const images = learningList.querySelectorAll("img"); if (images.length === 0) { console.log("학습 목록에 이미지 없음"); return; } console.log(`이미지 ${images.length}개 로딩 대기 중...`); const imagePromises = Array.from(images).map((img) => { if (img.complete) return Promise.resolve(); return new Promise((resolve) => { img.onload = () => { console.log("이미지 로드 완료:", img.src); resolve(); }; img.onerror = () => { console.warn("이미지 로드 실패:", img.src); resolve(); }; // 10초 타임아웃 setTimeout(resolve, 10000); }); }); await Promise.all(imagePromises); console.log("모든 이미지 로딩 완료: 높이 재조정"); this._adjustModalContentHeight(); this._adjustLearningListHeight(); // 이미지 로딩 후 다시 스크롤 (높이가 변경될 수 있으므로) setTimeout(() => { this._scrollToCurrentLesson(); }, 100); } // ============================================ // 이벤트 핸들러 // ============================================ /** * 닫기 이벤트 설정 (ModalUtils 활용) * @private */ _setupCloseEvents(modalElement) { // ModalUtils를 사용하여 공통 닫기 이벤트 설정 this._closeEventHandlers = ModalUtils.setupCloseEvents(modalElement, { onClose: () => this.close(), onCleanup: () => { // Observer 정리는 close() 메서드에서 처리 }, }); } /** * 키보드 이벤트 설정 * @private */ _setupKeyboardEvents() { try { const keyboardHandler = (e) => { try { if (e.key === "Escape") { if (this.currentModal && this.currentModal.style.display === "block") { this.close(); } } } catch (error) { this._handleError(error, '_setupKeyboardEvents.keyboardHandler'); } }; if (this.eventManager) { const listenerId = this.eventManager.on(document, "keydown", keyboardHandler); this.listenerIds.push({ element: document, id: listenerId, type: 'keydown' }); } else { document.addEventListener("keydown", keyboardHandler); } } catch (error) { this._handleError(error, '_setupKeyboardEvents'); } } /** * 모달 닫기 (ModalUtils 활용) */ async close() { if (!this.currentModal) return; this.currentModal.style.display = "none"; if (this.currentLearningIndex !== null) { const flushIncrement = Math.max(0, Math.floor(this._pendingAllTmSeconds || 0)); const currentLesson = await this._saveCurrentLessonProgress({ completed: false, isWatching: false, all_tm_increment: flushIncrement, }); this._pendingAllTmSeconds = Math.max(0, (this._pendingAllTmSeconds || 0) - flushIncrement); if (!currentLesson) { this.currentLearningIndex = null; this.currentChapterInfo = null; this._resetSkipRestriction(); return; } // 챕터 마커는 시작점 표시용이므로 완료 처리 안 함 if (currentLesson.isChapterMarker) { console.log( `[VideoModal] 챕터 마커는 완료 처리 안 함: ${currentLesson.label}` ); this.currentLearningIndex = null; this.currentChapterInfo = null; return; } // 완료된 학습을 다시 본 경우 (재학습) if (currentLesson.completed) { console.log( `[VideoModal] 재학습 완료: [${this.currentLearningIndex}] ${currentLesson.label} - 게이지 유지` ); // 게이지바는 이동하지 않음 (completeLesson 호출하지 않음) } else { const isCompletedByWatch = this._isLessonCompletionSatisfied(currentLesson); if (isCompletedByWatch) { await this._saveCurrentLessonProgress({ completed: true, isWatching: false }); console.log( `[VideoModal] 새 학습 완료: [${this.currentLearningIndex}] ${currentLesson.label}` ); this.markerManager.completeLesson(this.currentLearningIndex); } else { console.log( `[VideoModal] 부분 시청 저장: [${this.currentLearningIndex}] ${currentLesson.label}` ); } } this.currentLearningIndex = null; this.currentChapterInfo = null; } // 저장 후 비디오 정지 ModalUtils.stopVideo(this.currentModal); // 시간 스킵 제한 정리 this._resetSkipRestriction(); } /** * 모달 제거 (VideoModalBase 활용) */ async destroy() { try { if (this.currentModal) { // 이전 모달 닫을 때 완료 처리 (학습 페이지 특화) if (this.currentLearningIndex !== null && this.markerManager && this.markerManager.allMarkers) { const flushIncrement = Math.max(0, Math.floor(this._pendingAllTmSeconds || 0)); const currentLesson = await this._saveCurrentLessonProgress({ completed: false, isWatching: false, all_tm_increment: flushIncrement, }); this._pendingAllTmSeconds = Math.max(0, (this._pendingAllTmSeconds || 0) - flushIncrement); if (currentLesson) { // 챕터 마커는 완료 처리 안 함 if (!currentLesson.isChapterMarker) { // 완료된 학습을 다시 본 경우 (재학습) if (currentLesson.completed) { console.log( `[VideoModal] 재학습 완료 (destroy): [${this.currentLearningIndex}] ${currentLesson.label} - 게이지 유지` ); } else { const isCompletedByWatch = this._isLessonCompletionSatisfied(currentLesson); if (isCompletedByWatch) { await this._saveCurrentLessonProgress({ completed: true, isWatching: false }); console.log( `[VideoModal] 새 학습 완료 (destroy): [${this.currentLearningIndex}] ${currentLesson.label}` ); if (this.markerManager && typeof this.markerManager.completeLesson === 'function') { this.markerManager.completeLesson(this.currentLearningIndex); } } else { console.log( `[VideoModal] 부분 시청 저장 (destroy): [${this.currentLearningIndex}] ${currentLesson.label}` ); } } } } this.currentLearningIndex = null; this.currentChapterInfo = null; } // 시간 스킵 제한 정리 this._resetSkipRestriction(); // ModalUtils를 사용하여 비디오 정지 및 Observer 정리 if (typeof ModalUtils !== 'undefined' && ModalUtils && typeof ModalUtils.cleanup === 'function') { ModalUtils.cleanup(this.currentModal); } // 학습 페이지 특화 Observer 정리 if (this.resizeObserver) { this.resizeObserver.disconnect(); this.resizeObserver = null; } if (this.mutationObserver) { this.mutationObserver.disconnect(); this.mutationObserver = null; } // 타이머 정리 if (this.heightAdjustTimer) { clearTimeout(this.heightAdjustTimer); this.heightAdjustTimer = null; } // window resize 이벤트 리스너 제거 if (this._windowResizeHandler) { if (this.eventManager && this.listenerIds.length > 0) { // EventManager로 등록된 리스너 제거 this.listenerIds.forEach(({ element, id }) => { if (element === window && id) { this.eventManager.off(element, id); } }); this.listenerIds = this.listenerIds.filter(({ element }) => element !== window); } else { window.removeEventListener("resize", this._windowResizeHandler); } this._windowResizeHandler = null; } // 키보드 이벤트 리스너 제거 if (this.eventManager && this.listenerIds.length > 0) { this.listenerIds.forEach(({ element, id }) => { this.eventManager.off(element, id); }); this.listenerIds = []; } // 재시도 카운터 초기화 this._retryCount = 0; // VideoModalBase의 cleanupObservers도 호출 if (super.cleanupObservers && typeof super.cleanupObservers === 'function') { super.cleanupObservers(); } // 모달 제거 if (this.domUtils && this.domUtils.remove) { this.domUtils.remove(this.currentModal); } else { this.currentModal.remove(); } this.currentModal = null; this.currentModalElement = null; // VideoModalBase 호환성 } } catch (error) { this._handleError(error, 'destroy'); } } /** * 스킵 탐지 인터벌 시작 (재생/일시정지 모두에서 호출) * @private */ _startSkipInterval() { clearInterval(this._skipInterval); console.log('[LearningTime] 1분 저장 인터벌 시작'); this._skipInterval = setInterval(() => { try { const timingPlayer = this._getTimingPlayer(); if (!timingPlayer || typeof timingPlayer.getCurrentTime !== 'function') { return; } const t = timingPlayer.getCurrentTime(); if (typeof t !== 'number') return; let playerState = null; try { playerState = typeof timingPlayer.getPlayerState === 'function' ? timingPlayer.getPlayerState() : null; } catch (e) { /* ignore */ } const now = Date.now(); this._enforcePlaybackRateOneX(timingPlayer, 'skipInterval'); const duration = typeof timingPlayer.getDuration === 'function' ? Math.max(0, Math.floor(timingPlayer.getDuration() || 0)) : 0; if (playerState === 3 && t <= 0 && this.maxWatchedTime > 0 && now >= this._nextBufferRecoveryAt) { this._bufferRecoveryAttempts += 1; this._nextBufferRecoveryAt = now + 2000; console.log( `[LearningTime] 버퍼링 고정 감지 -> 재시도 (${this._bufferRecoveryAttempts}), resume=${Math.floor(this.maxWatchedTime)}s, duration=${duration}s` ); try { if (duration > 0 && typeof timingPlayer.seekTo === 'function') { const target = Math.min(this.maxWatchedTime, Math.max(0, duration - 1)); timingPlayer.seekTo(target, true); } if (typeof timingPlayer.playVideo === 'function') { timingPlayer.playVideo(); } } catch (e) { /* ignore */ } } const hasPlaybackState = playerState === 1 || playerState === 2; const hasProgressedTime = t > 0; if (hasPlaybackState || hasProgressedTime) { if (!this._timingHasActivePlayback) { this._timingHasActivePlayback = true; this._lastPlayerTime = t; console.log( `[LearningTime] 재생 활성화 감지 state=${playerState}, current=${Math.floor(t)}s` ); } this._bufferRecoveryAttempts = 0; this._nextBufferRecoveryAt = 0; } if (!this._timingHasActivePlayback) { if (this._lastTimingDebugAt === 0 || now - this._lastTimingDebugAt >= 10000) { console.log( `[LearningTime] 재생 대기중 state=${playerState}, current=${Math.floor(t || 0)}s, duration=${duration}s, source=${this._activeTimingPlayerSource || 'unknown'}` ); this._lastTimingDebugAt = now; } return; } if (this._lastPlayerTime <= 0) { this._lastPlayerTime = t; } const delta = t - this._lastPlayerTime; const allowedPlaybackStep = 5; if (delta > allowedPlaybackStep && t > this.maxWatchedTime + allowedPlaybackStep) { // 비정상적인 대폭 점프만 스킵으로 판단한다. if (typeof timingPlayer.seekTo === 'function') { timingPlayer.seekTo(this.maxWatchedTime, true); } this._lastPlayerTime = this.maxWatchedTime; } else { if ( this.currentLearningIndex !== null && this.markerManager && this.markerManager.allMarkers && typeof timingPlayer.getDuration === 'function' ) { const currentLesson = this.markerManager.allMarkers[this.currentLearningIndex]; if (currentLesson && !currentLesson.isChapterMarker) { const playerDuration = duration; const lessonDuration = Math.max(0, Number.parseInt(currentLesson.content_tm ?? 0, 10) || 0); if (playerDuration > lessonDuration) { currentLesson.content_tm = playerDuration; } } } if (delta > 0) { this._pendingAllTmSeconds += delta; } this.maxWatchedTime = Math.max(this.maxWatchedTime, t); this._lastPlayerTime = t; if (this._lastLearningSaveAt === 0) { this._lastLearningSaveAt = now; console.log('[LearningTime] 1분 저장 기준 시각 설정'); } if (this._lastTimingDebugAt === 0 || now - this._lastTimingDebugAt >= 10000) { let ytTime = -1; let ytState = null; let customTime = -1; let customState = null; try { ytTime = this.ytPlayer && typeof this.ytPlayer.getCurrentTime === 'function' ? Number(this.ytPlayer.getCurrentTime()) : -1; ytState = this.ytPlayer && typeof this.ytPlayer.getPlayerState === 'function' ? this.ytPlayer.getPlayerState() : null; } catch (e) { /* ignore */ } try { customTime = this._ytPlayer && typeof this._ytPlayer.getCurrentTime === 'function' ? Number(this._ytPlayer.getCurrentTime()) : -1; customState = this._ytPlayer && typeof this._ytPlayer.getPlayerState === 'function' ? this._ytPlayer.getPlayerState() : null; } catch (e) { /* ignore */ } console.log( `[LearningTime] 진행중 watch_tm=${Math.floor(this.maxWatchedTime || 0)}s, current=${Math.floor(t || 0)}s, delta=${Number(delta || 0).toFixed(2)}s, pending_all_tm=${Math.floor(this._pendingAllTmSeconds || 0)}s, source=${this._activeTimingPlayerSource || 'unknown'}, ytPlayer=${Math.floor(ytTime || 0)}s(s=${ytState}), _ytPlayer=${Math.floor(customTime || 0)}s(s=${customState})` ); this._lastTimingDebugAt = now; } if (now - this._lastLearningSaveAt >= 60000) { const increment = Math.max(0, Math.floor(this._pendingAllTmSeconds || 0)); if (increment > 0) { console.log(`[LearningTime] 1분 저장 실행 increment=${increment}s`); this._saveCurrentLessonProgress({ completed: false, isWatching: true, all_tm_increment: increment, }); this._pendingAllTmSeconds = Math.max(0, (this._pendingAllTmSeconds || 0) - increment); } else { console.log('[LearningTime] 1분 경과했지만 increment=0으로 저장 생략'); } this._lastLearningSaveAt = now; } } } catch (e) { console.warn('[LearningTime] 인터벌 처리 중 예외 (다음 tick 재시도):', e?.message || e); } }, 300); } }