/**
* 비디오 모달 관리 클래스 (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);
}
}