[251115] fix: MediaPanel.v3.jsx 비디오재생-1

🕐 커밋 시간: 2025. 11. 15. 12:22:59

📊 변경 통계:
  • 총 파일: 12개
  • 추가: +246줄
  • 삭제: -27줄

📁 추가된 파일:
  + com.twin.app.shoptime/src/utils/focusPanelGuide.js

📝 수정된 파일:
  ~ com.twin.app.shoptime/src/actions/actionTypes.js
  ~ com.twin.app.shoptime/src/actions/mediaActions.js
  ~ com.twin.app.shoptime/src/actions/panelActions.js
  ~ com.twin.app.shoptime/src/reducers/panelReducer.js
  ~ com.twin.app.shoptime/src/utils/SpotlightIds.js
  ~ com.twin.app.shoptime/src/views/DetailPanel/DetailPanel.jsx
  ~ com.twin.app.shoptime/src/views/DetailPanel/ProductAllSection/ProductAllSection.jsx
  ~ com.twin.app.shoptime/src/views/DetailPanel/ProductAllSection/ProductAllSection.module.less
  ~ com.twin.app.shoptime/src/views/DetailPanel/ProductContentSection/ProductVideo/ProductVideo.v3.jsx
  ~ com.twin.app.shoptime/src/views/DetailPanel/ProductContentSection/YouMayAlsoLike/YouMayAlsoLike.jsx
  ~ com.twin.app.shoptime/src/views/MediaPanel/MediaPanel.v3.jsx

🔧 함수 변경 내용:
  📄 com.twin.app.shoptime/src/actions/panelActions.js (javascript):
     Added: resetPanels()
  📄 com.twin.app.shoptime/src/views/DetailPanel/ProductAllSection/ProductAllSection.jsx (javascript):
    🔄 Modified: extractProductMeta()
  📄 com.twin.app.shoptime/src/views/DetailPanel/ProductContentSection/ProductVideo/ProductVideo.v3.jsx (javascript):
    🔄 Modified: Spottable()
  📄 com.twin.app.shoptime/src/views/DetailPanel/ProductContentSection/YouMayAlsoLike/YouMayAlsoLike.jsx (javascript):
    🔄 Modified: SpotlightContainerDecorator()
  📄 com.twin.app.shoptime/src/views/MediaPanel/MediaPanel.v3.jsx (javascript):
    🔄 Modified: normalizeModalStyle()
  📄 com.twin.app.shoptime/src/utils/focusPanelGuide.js (javascript):
     Added: DetailPanel(), handleProductSelect()

🔧 주요 변경 내용:
  • 타입 시스템 안정성 강화
  • 핵심 비즈니스 로직 개선
  • 공통 유틸리티 함수 최적화
This commit is contained in:
2025-11-15 12:23:00 +09:00
parent f140be087b
commit af30f8c688
12 changed files with 609 additions and 116 deletions

View File

@@ -6,6 +6,7 @@ export const types = {
POP_PANEL: 'POP_PANEL',
UPDATE_PANEL: 'UPDATE_PANEL',
RESET_PANELS: 'RESET_PANELS',
FOCUS_PANEL: 'FOCUS_PANEL', // 🔽 [251114] 명시적 포커스 이동
// 🔽 [신규] panel history actions
ENQUEUE_PANEL_HISTORY: 'ENQUEUE_PANEL_HISTORY',
@@ -82,12 +83,12 @@ export const types = {
CLEAR_CART: 'CLEAR_CART',
//cart api action
GET_MY_INFO_CART_SEARCH: 'GET_MY_INFO_CART_SEARCH',
INSERT_MY_INFO_CART : "INSERT_MY_INFO_CART",
DELETE_MY_INFO_CART : "DELETE_MY_INFO_CART",
DELETE_ALL_MY_INFO_CART : "DELETE_ALL_MY_INFO_CART",
UPDATE_MY_INFO_CART : "UPDATE_MY_INFO_CART",
INSERT_MY_INFO_CART: 'INSERT_MY_INFO_CART',
DELETE_MY_INFO_CART: 'DELETE_MY_INFO_CART',
DELETE_ALL_MY_INFO_CART: 'DELETE_ALL_MY_INFO_CART',
UPDATE_MY_INFO_CART: 'UPDATE_MY_INFO_CART',
//cart checkbox toggle action
TOGGLE_CHECK_CART : "TOGGLE_CHECK_CART",
TOGGLE_CHECK_CART: 'TOGGLE_CHECK_CART',
// appData actions
ADD_MAIN_INDEX: 'ADD_MAIN_INDEX',
@@ -315,7 +316,7 @@ export const types = {
SET_MODAL_BORDER: 'SET_MODAL_BORDER',
SET_BANNER_VISIBILITY: 'SET_BANNER_VISIBILITY',
// 🔽 [추가] JustForYou 상품 관리 부분
// 🔽 [추가] JustForYou 상품 관리 부분
JUSTFORYOU: 'JUSTFORYOU',
// 🔽 Voice Conductor 관련 액션 타입

View File

@@ -23,15 +23,16 @@ export const startMediaPlayer =
const topPanel = panels[panels.length - 1];
let panelWorkingAction = pushPanel;
console.log('[startMediaPlayer] ========== Called ==========');
console.log('[startMediaPlayer] Current panels:', JSON.stringify(panels, null, 2));
console.log('[startMediaPlayer] topPanel:', JSON.stringify(topPanel, null, 2));
console.log('[startMediaPlayer]-LoadingVideo 🚀 시작:', {
showUrl: rest?.showUrl?.substring(0, 50),
showNm: rest?.showNm,
prdtId: rest?.prdtId,
modal,
modalContainerId,
});
if (topPanel && topPanel.name === panel_names.MEDIA_PANEL) {
panelWorkingAction = updatePanel;
console.log('[startMediaPlayer] Using updatePanel (existing MediaPanel)');
} else {
console.log('[startMediaPanel] Using pushPanel (new MediaPanel)');
}
const allParams = {
@@ -42,8 +43,6 @@ export const startMediaPlayer =
...rest,
};
console.log('[startMediaPlayer] All parameters:', JSON.stringify(allParams, null, 2));
dispatch(
panelWorkingAction(
{
@@ -54,7 +53,7 @@ export const startMediaPlayer =
)
);
console.log('[startMediaPlayer] Panel action dispatched');
console.log('[startMediaPlayer]-LoadingVideo ✅ MediaPanel dispatch 완료');
if (modal && modalContainerId && !spotlightDisable) {
Spotlight.setPointerMode(false);

View File

@@ -1,4 +1,5 @@
import { types } from "./actionTypes";
import { types } from './actionTypes';
import Spotlight from '@enact/spotlight';
/*
name: panel_names.PLAYER_PANEL,
@@ -27,3 +28,85 @@ export const resetPanels = (panels) => ({
type: types.RESET_PANELS,
payload: panels,
});
/**
* [251114] 명시적 포커스 이동
* Panel의 비동기 작업(useEffect, 타이머 등)이 포커스를 탈취하는 것을 방지
* @param {string} panelName - 대상 Panel 이름
* @param {string} focusTarget - 포커스할 요소 ID
* @returns {Function} Redux thunk
*/
export const focusPanel = (panelName, focusTarget) => {
return (dispatch, getState) => {
const state = getState();
const panels = state.panels.panels;
console.log('[focusPanel] 포커스 이동 시도', {
panelName,
focusTarget,
currentPanels: panels.map((p) => p.name),
timestamp: Date.now(),
});
// 안전성 체크 1: Panel이 존재하고 최상단 또는 그 아래에 있는가?
const targetPanelIndex = panels.findIndex((p) => p.name === panelName);
const targetPanel = panels[targetPanelIndex];
const topPanel = panels[panels.length - 1];
if (!targetPanel) {
console.warn(`[focusPanel] ❌ Panel을 찾을 수 없음: ${panelName}`);
return;
}
// Panel이 최상단 또는 그 아래 레이어에 있는지 확인
// MediaPanel(최상단) 위에 다른 Modal이 있는 경우는 허용하지 않음
const panelsAboveTarget = panels.slice(targetPanelIndex + 1);
const hasBlockingModalAbove = panelsAboveTarget.some(
(panel) => panel?.panelInfo?.modal === true && panel.name !== panelName
);
if (hasBlockingModalAbove) {
const blockingModal = panelsAboveTarget.find((panel) => panel?.panelInfo?.modal === true);
console.warn(
`[focusPanel] ⚠️ 상위에 Modal이 있음. ` +
`${panelName}(${targetPanelIndex}층)에 포커스할 수 없음. ` +
`상단 Modal: ${blockingModal?.name}(${panelsAboveTarget.indexOf(blockingModal) + targetPanelIndex + 1}층)`
);
return;
}
console.log(
`[focusPanel] ✅ Panel 위치 확인: ${panelName}(${targetPanelIndex}층), ` +
`전체 Panel: ${panels.length}`
);
// 포커스 이동
setTimeout(() => {
const element = document.getElementById(focusTarget);
if (!element) {
console.warn(`[focusPanel] ❌ 요소를 찾을 수 없음: ${focusTarget}`);
return;
}
if (element.offsetParent === null) {
console.warn(`[focusPanel] ⚠️ 요소가 숨겨져있음: ${focusTarget}`);
return;
}
// ✅ 포커스 이동
Spotlight.focus(focusTarget);
console.log(`[focusPanel] ✅ 포커스 이동 성공: ${panelName}${focusTarget}`);
// Reducer에 반영
dispatch({
type: types.FOCUS_PANEL,
payload: {
panelName,
focusTarget,
timestamp: Date.now(),
},
});
}, 0);
};
};

View File

@@ -5,6 +5,7 @@ const initialState = {
// 기존 상태 - 완전히 호환됨
panels: [],
lastPanelAction: '', //"", "push", "pop", "update", "reset", "previewPush", "previewPop", "previewUpdate"
lastFocusTarget: null, // [251114] 마지막 포커스 대상 (panelName + elementId)
// [251106] 패널 액션 큐 관련 상태 - 기존 기능에 전혀 영향 없음
panelActionQueue: [], // 처리 대기 중인 패널 액션 큐
@@ -13,7 +14,7 @@ const initialState = {
queueStats: {
totalProcessed: 0, // 총 처리된 액션 수
failedCount: 0, // 실패한 액션 수
averageProcessingTime: 0 // 평균 처리 시간
averageProcessingTime: 0, // 평균 처리 시간
},
// [251106] 비동기 액션 관련 상태
@@ -30,7 +31,7 @@ export const panelsReducer = (state = initialState, action) => {
case types.PUSH_PANEL: {
console.log('[panelReducer] 🔵 PUSH_PANEL START', {
newPanelName: action.payload.name,
currentPanels: state.panels.map(p => p.name),
currentPanels: state.panels.map((p) => p.name),
duplicatable: action.duplicatable,
});
@@ -76,7 +77,7 @@ export const panelsReducer = (state = initialState, action) => {
}
console.log('[panelReducer] 🔵 PUSH_PANEL END', {
resultPanels: newState.map(p => p.name),
resultPanels: newState.map((p) => p.name),
lastAction,
});
@@ -90,7 +91,7 @@ export const panelsReducer = (state = initialState, action) => {
case types.POP_PANEL: {
console.log('[panelReducer] 🔴 POP_PANEL START', {
targetPanel: action.payload || 'last_panel',
currentPanels: state.panels.map(p => p.name),
currentPanels: state.panels.map((p) => p.name),
});
let lastAction = 'pop';
@@ -113,7 +114,7 @@ export const panelsReducer = (state = initialState, action) => {
}
console.log('[panelReducer] 🔴 POP_PANEL END', {
resultPanels: resultPanels.map(p => p.name),
resultPanels: resultPanels.map((p) => p.name),
lastAction,
});
@@ -159,7 +160,7 @@ export const panelsReducer = (state = initialState, action) => {
}
case types.RESET_PANELS: {
console.log('[panelReducer] 🟢 RESET_PANELS START', {
currentPanels: state.panels.map(p => p.name),
currentPanels: state.panels.map((p) => p.name),
payloadProvided: !!action.payload,
});
@@ -171,7 +172,7 @@ export const panelsReducer = (state = initialState, action) => {
: [];
console.log('[panelReducer] 🟢 RESET_PANELS END', {
resultPanels: updatedPanels.map(p => p.name),
resultPanels: updatedPanels.map((p) => p.name),
});
return {
@@ -267,7 +268,8 @@ export const panelsReducer = (state = initialState, action) => {
const processingTime = Date.now() - startTime;
const newTotalProcessed = state.queueStats.totalProcessed + 1;
const newAverageTime =
(state.queueStats.averageProcessingTime * state.queueStats.totalProcessed + processingTime) /
(state.queueStats.averageProcessingTime * state.queueStats.totalProcessed +
processingTime) /
newTotalProcessed;
console.log('[panelReducer] ✅ QUEUE_ITEM_PROCESSED', {
@@ -352,7 +354,7 @@ export const panelsReducer = (state = initialState, action) => {
...action.payload,
status: 'running',
startTime: Date.now(),
}
},
},
queueError: null, // 에러 초기화
};
@@ -381,16 +383,19 @@ export const panelsReducer = (state = initialState, action) => {
result: action.payload.result,
executionTime,
completedAt: action.payload.timestamp,
}
},
].slice(-100), // 최근 100개만 유지
queueError: null,
queueStats: {
...state.queueStats,
totalProcessed: state.queueStats.totalProcessed + 1,
averageProcessingTime: Math.round(
((state.queueStats.averageProcessingTime * state.queueStats.totalProcessed) + executionTime) /
(state.queueStats.totalProcessed + 1) * 100
) / 100,
averageProcessingTime:
Math.round(
((state.queueStats.averageProcessingTime * state.queueStats.totalProcessed +
executionTime) /
(state.queueStats.totalProcessed + 1)) *
100
) / 100,
},
};
}
@@ -419,7 +424,7 @@ export const panelsReducer = (state = initialState, action) => {
error: action.payload.error,
executionTime,
failedAt: action.payload.timestamp,
}
},
].slice(-100), // 최근 100개만 유지
queueError: {
actionId: action.payload.actionId,
@@ -433,6 +438,24 @@ export const panelsReducer = (state = initialState, action) => {
};
}
// [251114] 명시적 포커스 이동
case types.FOCUS_PANEL: {
console.log('[panelReducer] 🎯 FOCUS_PANEL', {
panelName: action.payload.panelName,
focusTarget: action.payload.focusTarget,
timestamp: action.payload.timestamp,
});
return {
...state,
lastFocusTarget: {
panelName: action.payload.panelName,
focusTarget: action.payload.focusTarget,
timestamp: action.payload.timestamp,
},
};
}
default:
return state;
}

View File

@@ -1,59 +1,60 @@
export const SpotlightIds = {
TPANEL: "tpanel",
TBODY: "tbody",
TPOPUP: "tpopup",
HOME_TBODY: "home_tbody",
TITEM_CARD: "tItemCard",
TAB_LAYOUT: "tablayout",
TPANEL: 'tpanel',
TBODY: 'tbody',
TPOPUP: 'tpopup',
HOME_TBODY: 'home_tbody',
TITEM_CARD: 'tItemCard',
TAB_LAYOUT: 'tablayout',
// homePanel
HOME_CATEGORY_NAV: "homeCategoryNav",
HOME_CATEGORY_NAV: 'homeCategoryNav',
// FeaturedBrandsPanel
BRAND_VERTICAL_PAGENATOR: "brandVerticalPagenator",
BRAND_QUICK_MENU: "brandQuickMenu",
BRAND_TOP_BUTTON: "brandTopButton",
BRAND_VERTICAL_PAGENATOR: 'brandVerticalPagenator',
BRAND_QUICK_MENU: 'brandQuickMenu',
BRAND_TOP_BUTTON: 'brandTopButton',
// TrendingNowPanel
TRENDING_NOW_VERTICAL_PAGINATOR: "trendingNowVerticalPaginator",
TRENDING_NOW_POPULAR_SHOW: "trendingNowPopularShow",
TRENDING_NOW_POPULAR_VIDEO: "trendingNowPopularVideo",
TRENDING_NOW_POPULAR_GRID_LIST: "trendingNowVirtualGridList",
TRENDING_NOW_PREV_INDICATOR: "trendingNowPrevIndicator",
TRENDING_NOW_NEXT_INDICATOR: "trendingNowNextIndicator",
TRENDING_NOW_BEST_SELLER: "trendingNowBestSeller",
TRENDING_NOW_TOP_BUTTON: "trendingNowTopButton",
TRENDING_NOW_VERTICAL_PAGINATOR: 'trendingNowVerticalPaginator',
TRENDING_NOW_POPULAR_SHOW: 'trendingNowPopularShow',
TRENDING_NOW_POPULAR_VIDEO: 'trendingNowPopularVideo',
TRENDING_NOW_POPULAR_GRID_LIST: 'trendingNowVirtualGridList',
TRENDING_NOW_PREV_INDICATOR: 'trendingNowPrevIndicator',
TRENDING_NOW_NEXT_INDICATOR: 'trendingNowNextIndicator',
TRENDING_NOW_BEST_SELLER: 'trendingNowBestSeller',
TRENDING_NOW_TOP_BUTTON: 'trendingNowTopButton',
// myPagePanel
MY_PAGE_FAVORITES_BOX: "myPageFavoritesBox",
MY_PAGE_REMINDRES_BOX: "myPageRemindresBox",
MY_PAGE_MY_ORDER_BOX: "myPageMyOrderBox",
MY_PAGE_MY_ORDER_TAB_CONTAINER: "myPageMyOrderTabContainer",
MY_PAGE_FAVORITES_BOX: 'myPageFavoritesBox',
MY_PAGE_REMINDRES_BOX: 'myPageRemindresBox',
MY_PAGE_MY_ORDER_BOX: 'myPageMyOrderBox',
MY_PAGE_MY_ORDER_TAB_CONTAINER: 'myPageMyOrderTabContainer',
// categoryPanel
CATEGORY_CONTENTS_BOX: "categoryContentsBox",
CATEGORY_TAB_CONTAINER: "categorytabContainer",
SHOW_PRODUCTS_BOX: "showProductsBox",
SHOW_CONTENTS_BOX: "showContentsBox",
CATEGORY_CONTENTS_BOX: 'categoryContentsBox',
CATEGORY_TAB_CONTAINER: 'categorytabContainer',
SHOW_PRODUCTS_BOX: 'showProductsBox',
SHOW_CONTENTS_BOX: 'showContentsBox',
// video player
PLAYER_SKIPINTRO: "skipintro",
PLAYER_TITLE_LAYER: "playerTitleLayer",
PLAYER_SLIDER: "playerslider",
PLAYER_TAB_BUTTON: "playerTabArrow",
PLAYER_BACK_BUTTON: "player-back-button",
PLAYER_SUBTITLE_BUTTON: "player-subtitlebutton",
PLAYER_SKIPINTRO: 'skipintro',
PLAYER_TITLE_LAYER: 'playerTitleLayer',
PLAYER_SLIDER: 'playerslider',
PLAYER_TAB_BUTTON: 'playerTabArrow',
PLAYER_BACK_BUTTON: 'player-back-button',
PLAYER_SUBTITLE_BUTTON: 'player-subtitlebutton',
// searchPanel
SEARCH_THEME: "search_theme",
SEARCH_SHOW: "search_show",
SEARCH_ITEM: "search_item",
SEARCH_BESTSELLER: "search_bestseller",
SEARCH_TAB_CONTAINER: "searchtabContainer",
SEARCH_THEME: 'search_theme',
SEARCH_SHOW: 'search_show',
SEARCH_ITEM: 'search_item',
SEARCH_BESTSELLER: 'search_bestseller',
SEARCH_TAB_CONTAINER: 'searchtabContainer',
// pin Code Popup
PINCODE_CONTAINER: "pincodeContainer",
PINCODE_CONTAINER: 'pincodeContainer',
// detailPanel
DETAIL_BUYNOW: "detail_buynow",
DETAIL_SHOPBYMOBILE: "detail_shop_by_mobile",
DETAIL_BUYNOW: 'detail_buynow',
DETAIL_SHOPBYMOBILE: 'detail_shop_by_mobile',
DETAIL_PRODUCTVIDEO: 'product-video-player',
};

View File

@@ -0,0 +1,286 @@
/**
* [251114] focusPanel 액션 사용 가이드
*
* Panel의 비동기 작업(useEffect, 타이머 등)이 포커스를 탈취하는 문제를 해결하기 위한
* 명시적 포커스 제어 시스템입니다.
*
* 문제 상황:
* - updatePanel 호출 → Panel의 useEffect 시작
* - pushPanel 호출 → 새 Panel에 포커스 설정
* - Panel의 useEffect 완료 → 기존 Panel이 포커스 탈취 (원하지 않는 동작)
*
* 해결책:
* - updatePanel, pushPanel 등으로 Panel을 제어할 때
* - focusPanel()로 명시적으로 포커스 대상을 지정
* - Panel의 useEffect는 더 이상 자동 포커스 이동 안 함
*/
import { focusPanel } from '../actions/panelActions';
import { panel_names } from './Config';
// ============================================================================
// 예시 1: 단일 Panel 포커스 제어 (가장 간단)
// ============================================================================
export const example1_singlePanelFocus = (dispatch) => {
console.log('✅ 예시 1: 단일 Panel 포커스 제어');
// DETAIL_PANEL 표시 + 포커스 설정
dispatch(pushPanel({ name: panel_names.DETAIL_PANEL }));
dispatch(focusPanel(panel_names.DETAIL_PANEL, 'detail-buy-button'));
};
// ============================================================================
// 예시 2: 2중 구조 (PLAYER + DETAIL)에서 포커스 제어
// ============================================================================
export const example2_twoLayerFocus = (dispatch) => {
console.log('✅ 예시 2: 2중 구조 포커스 제어');
// 상황: [PLAYER_PANEL]이 이미 있는 상태
// 1단계: DETAIL_PANEL 추가
dispatch(pushPanel({ name: panel_names.DETAIL_PANEL }));
// 2단계: DETAIL_PANEL의 특정 요소에 포커스
dispatch(focusPanel(panel_names.DETAIL_PANEL, 'detail-buy-button'));
// 또는 PLAYER_PANEL의 플레이버튼에 포커스
// dispatch(focusPanel(panel_names.PLAYER_PANEL, 'player-play-button'));
};
// ============================================================================
// 예시 3: 3중 구조 (PLAYER + DETAIL + MEDIA)에서 포커스 제어
// ============================================================================
export const example3_threeLayerFocus = (dispatch) => {
console.log('✅ 예시 3: 3중 구조 포커스 제어');
// 상황: [PLAYER_PANEL, DETAIL_PANEL]이 이미 있는 상태
// 1단계: MEDIA_PANEL 추가 (modal=true)
dispatch(
pushPanel({
name: panel_names.MEDIA_PANEL,
panelInfo: { modal: true },
})
);
// 2단계: MEDIA_PANEL의 닫기 버튼에 포커스
dispatch(focusPanel(panel_names.MEDIA_PANEL, 'media-close-button'));
// 결과:
// - PLAYER_PANEL (백그라운드)
// - DETAIL_PANEL (중간, 보임)
// - MEDIA_PANEL (맨 위, 모달, 포커스 받음)
};
// ============================================================================
// 예시 4: updatePanel과 함께 사용
// ============================================================================
import { updatePanel } from '../actions/panelActions';
export const example4_updateWithFocus = (dispatch) => {
console.log('✅ 예시 4: updatePanel과 focusPanel 함께 사용');
// ❌ 문제가 될 수 있는 방식:
// dispatch(updatePanel({
// name: 'DETAIL_PANEL',
// panelInfo: { productId: '123', isLoading: false }
// }));
// DETAIL_PANEL의 useEffect가 나중에 포커스를 탈취할 수 있음
// ✅ 올바른 방식:
dispatch(
updatePanel({
name: panel_names.DETAIL_PANEL,
panelInfo: { productId: '123', isLoading: false },
})
);
// 명시적으로 포커스 지정
dispatch(focusPanel(panel_names.DETAIL_PANEL, 'detail-product-title'));
};
// ============================================================================
// 예시 5: API 호출 후 Panel 제어 및 포커스
// ============================================================================
export const example5_apiAndFocus = (dispatch, getState) => {
console.log('✅ 예시 5: API 호출 후 Panel 제어 및 포커스');
// API 호출 시뮬레이션
fetch('/api/products/123')
.then((res) => res.json())
.then((data) => {
// API 성공 → Panel 업데이트
dispatch(
updatePanel({
name: panel_names.DETAIL_PANEL,
panelInfo: {
product: data,
isLoading: false,
},
})
);
// 명시적으로 포커스 지정 (DETAIL_PANEL의 useEffect보다 먼저 실행되지만
// focusPanel은 비동기로 처리되므로 먼저 끝나더라도 안전)
dispatch(focusPanel(panel_names.DETAIL_PANEL, 'detail-add-to-cart-button'));
})
.catch((error) => {
// API 실패 → 에러 Panel 표시
dispatch(
pushPanel({
name: panel_names.ERROR_PANEL,
panelInfo: { error: error.message },
})
);
// 에러 Panel의 확인 버튼에 포커스
dispatch(focusPanel(panel_names.ERROR_PANEL, 'error-ok-button'));
});
};
// ============================================================================
// 예시 6: Queue와 함께 사용 (순서 보장)
// ============================================================================
import { pushPanelQueued, updatePanelQueued } from '../actions/queuedPanelActions';
export const example6_queueWithFocus = (dispatch) => {
console.log('✅ 예시 6: Panel Queue와 focusPanel 함께 사용');
// Panel 제어는 Queue로 순서 보장
dispatch(
updatePanelQueued({
name: panel_names.DETAIL_PANEL,
panelInfo: { productId: '456', isLoading: false },
})
);
dispatch(
pushPanelQueued({
name: panel_names.MEDIA_PANEL,
panelInfo: { modal: true },
})
);
// 포커스는 명시적으로 지정
dispatch(focusPanel(panel_names.MEDIA_PANEL, 'media-close-button'));
};
// ============================================================================
// 예시 7: Panel에서 focusPanel 호출
// ============================================================================
/*
// DetailPanel.jsx에서:
import { useDispatch } from 'react-redux';
import { focusPanel } from '../actions/panelActions';
import { panel_names } from '../utils/Config';
function DetailPanel({ panelInfo }) {
const dispatch = useDispatch();
const handleProductSelect = (productId) => {
// 선택한 상품으로 Panel 업데이트
dispatch(updatePanel({
name: panel_names.DETAIL_PANEL,
panelInfo: { productId }
}));
// 특정 요소에 포커스 (Panel의 useEffect가 포커스를 탈취하지 않음)
dispatch(focusPanel(
panel_names.DETAIL_PANEL,
'detail-product-description'
));
};
return (
<div>
<button onClick={() => handleProductSelect('123')}>
Product 123
</button>
</div>
);
}
*/
// ============================================================================
// 예시 8: 에러 처리
// ============================================================================
export const example8_errorHandling = (dispatch) => {
console.log('✅ 예시 8: 에러 처리');
// focusPanel은 안전성 체크를 수행합니다:
// 1. Panel이 존재하지 않으면:
// [focusPanel] ❌ Panel을 찾을 수 없음: INVALID_PANEL
// 2. 상위에 Modal이 있으면 (새로운 로직):
// [focusPanel] ⚠️ 상위에 Modal이 있음. DETAIL_PANEL(1층)에 포커스할 수 없음.
// 상단 Modal: ANOTHER_MODAL_PANEL(3층)
// 3. 요소를 찾을 수 없으면:
// [focusPanel] ❌ 요소를 찾을 수 없음: invalid-id
// 4. 요소가 숨겨져있으면:
// [focusPanel] ⚠️ 요소가 숨겨져있음: hidden-element
// ✅ 성공 로그 (새로 추가):
// [focusPanel] ✅ Panel 위치 확인: DETAIL_PANEL(1층), 전체 Panel: 3층
// 모든 경우에 console에 상세한 로그가 출력되므로 디버깅이 쉬움
};
// ============================================================================
// 핵심 원칙
// ============================================================================
/*
1. updatePanel/pushPanel 호출 후 focusPanel 호출
dispatch(updatePanel(...));
dispatch(focusPanel(panelName, elementId));
2. Panel의 useEffect에서는 focusPanel을 호출하지 말 것
- Panel의 로직은 updatePanel의 panelInfo 변경만 감지
- 포커스 제어는 액션 호출처에서 명시적으로 지정
3. focusPanel은 매번 안전성을 체크함
- Panel 존재 여부
- Panel 레이어 위치 (최상단 또는 그 아래 패널만 허용)
- 상위 Modal 존재 여부 (상위에 Modal이 있으면 포커스 차단)
- 요소 존재 여부
- 요소 가시성
4. 2중/3중 구조에서도 안전함
- 최상단 또는 그 아래 레이어 패널에만 포커스 허용
- 상위에 Modal이 있는 경우에만 포커스 차단
- [PLAYER_PANEL, DETAIL_PANEL, MEDIA_PANEL] 구조에서:
• MEDIA_PANEL에 포커스: ✅ 가능 (최상단)
• DETAIL_PANEL에 포커스: ✅ 가능 (MEDIA_PANEL이 Modal이어도 하위 패널이므로 가능)
• PLAYER_PANEL에 포커스: ✅ 가능 (최하위 패널)
- 렌더링되지 않은 패널의 요소에 접근 불가
*/
// ============================================================================
// Redux DevTools에서 확인
// ============================================================================
/*
Redux DevTools를 열면:
FOCUS_PANEL 액션이 나타나고, payload에:
{
panelName: 'DETAIL_PANEL',
focusTarget: 'detail-buy-button',
timestamp: 1731014400000
}
state.panels.lastFocusTarget에도 같은 정보가 저장되어
마지막 포커스 상태를 추적할 수 있습니다.
*/

View File

@@ -662,8 +662,10 @@ export default function DetailPanel({ panelInfo, isOnTop, spotlightId }) {
);
const handleProductAllSectionReady = useCallback(() => {
console.log('############## ShopByMobile focus');
const spotTime = setTimeout(() => {
Spotlight.focus(SpotlightIds.DETAIL_SHOPBYMOBILE);
// Spotlight.focus(SpotlightIds.DETAIL_SHOPBYMOBILE);
Spotlight.focus(SpotlightIds.DETAIL_PRODUCTVIDEO);
}, 100);
return () => {
clearTimeout(spotTime);
@@ -726,7 +728,7 @@ export default function DetailPanel({ panelInfo, isOnTop, spotlightId }) {
console.log('[DetailPanel] MediaPanel modal=true detected - focusing ProductVideo');
const focusTimer = setTimeout(() => {
Spotlight.focus('product-video-player');
}, 500);
}, 2500);
return () => clearTimeout(focusTimer);
}
}, [panels]);

View File

@@ -341,7 +341,57 @@ export default function ProductAllSection({
dispatch(resetShowAllReviews());
}, []); // 빈 dependency array = 마운트 시에만 실행
// ⚠️ REMOVED: 상품 변경 시 필터 초기화 로직 제거
// 임시: 무조건 1.5초 후에 product-video-player에 포커스
useEffect(() => {
console.log(
'[ProductAllSection] 포커스 시도 전 - hasVideo:',
hasVideo,
'productVideoVersion:',
productVideoVersion
);
const timer = setTimeout(() => {
console.log('[ProductAllSection] 포커스 호출 시도: product-video-player');
// DOM에 요소가 존재하는지 확인
const element =
document.querySelector('[data-spotlight-id="product-video-player"]') ||
document.getElementById('product-video-player') ||
document.querySelector('[spotlight-id="product-video-player"]');
console.log('[ProductAllSection] DOM 요소 확인:', {
element: element,
elementExists: !!element,
elementTag: element?.tagName,
elementId: element?.id,
elementSpotlightId:
element?.getAttribute('data-spotlight-id') || element?.getAttribute('spotlight-id'),
});
try {
Spotlight.focus('product-video-player');
console.log('[ProductAllSection] 포커스 호출 성공');
// 포커스 후 현재 포커스된 요소 확인
setTimeout(() => {
const activeElement = document.activeElement;
console.log('[ProductAllSection] 포커스 후 activeElement:', {
activeElement: activeElement,
activeElementTag: activeElement?.tagName,
activeElementId: activeElement?.id,
activeElementSpotlightId:
activeElement?.getAttribute('data-spotlight-id') ||
activeElement?.getAttribute('spotlight-id'),
});
}, 100);
} catch (error) {
console.error('[ProductAllSection] 포커스 호출 실패:', error);
}
}, 1500); // 1.5초 = 1500ms
return () => {
clearTimeout(timer);
};
}, []);
// 이미 useReviews 훅에서 동일한 기능을 수행하고 있음
// ProductAllSection에서 중복으로 호출하면 UserReviewPanel 진입 시
// reviewListData가 반복적으로 초기화되어 Chrome에서 진입 불가 발생
@@ -859,10 +909,16 @@ export default function ProductAllSection({
prevMediaPanelModalStateRef.current === false
) {
console.log(
'[ProductAllSection] 🔄 MediaPanel이 전체화면에서 모달로 복귀 - ProductVideo로 포커스 복구'
'[ProductAllSection] 🔄 MediaPanel이 전체화면에서 모달로 복귀 - ProductVideo로 포커스 복구 시도'
);
const focusTimer = setTimeout(() => {
Spotlight.focus('product-video-player');
console.log('[ProductAllSection] MediaPanel 복귀 후 포커스 호출: product-video-player');
try {
Spotlight.focus('product-video-player');
console.log('[ProductAllSection] MediaPanel 복귀 후 포커스 호출 성공');
} catch (error) {
console.error('[ProductAllSection] MediaPanel 복귀 후 포커스 호출 실패:', error);
}
}, 100);
return () => clearTimeout(focusTimer);
}
@@ -1148,6 +1204,7 @@ export default function ProductAllSection({
onVideoPlaying={() => setIsVideoPlaying(true)}
onScrollToImages={handleScrollToImagesV1}
onFocus={() => console.log('[ProductVideo V1] Focused')}
data-spotlight-id="product-video-player-container"
/>
) : (
<ProductVideoV2

View File

@@ -488,6 +488,7 @@
display: flex !important;
align-items: center !important;
justify-content: center !important;
cursor: default !important;
&.shopByMobileOne {
margin: 0 6px 0 0;
}

View File

@@ -75,9 +75,18 @@ export default function ProductVideo({
}, [topPanel]);
// autoPlay 기능: 컴포넌트 마운트 시 자동으로 비디오 재생
// 단, 이미 MediaPanel이 modal로 재생 중이면 autoPlay 하지 않음
useEffect(() => {
if (autoPlay && canPlayVideo && !hasAutoPlayed && productInfo) {
console.log('[ProductVideo] Auto-playing video');
// MediaPanel이 이미 modal로 재생 중인지 확인
const isMediaPanelAlreadyPlaying =
topPanel?.name === panel_names.MEDIA_PANEL && topPanel?.panelInfo?.modal === true;
if (autoPlay && canPlayVideo && !hasAutoPlayed && productInfo && !isMediaPanelAlreadyPlaying) {
console.log('[ProductVideo]-LoadingVideo 🎯 AutoPlay 시작:', {
prdtId: productInfo?.prdtId,
prdtNm: productInfo?.prdtNm,
prdtMediaUrl: productInfo?.prdtMediaUrl?.substring(0, 50),
});
setHasAutoPlayed(true);
// 짧은 딜레이 후 재생 시작 (컴포넌트 마운트 완료 후)
@@ -121,6 +130,8 @@ export default function ProductVideo({
dispatch,
modalClassNameChange,
continuousPlay,
topPanel,
panels,
]);
// 비디오 재생 가능 여부 체크

View File

@@ -1,40 +1,20 @@
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
useDispatch,
useSelector,
} from 'react-redux';
import { useDispatch, useSelector } from 'react-redux';
import { Job } from '@enact/core/util';
import SpotlightContainerDecorator
from '@enact/spotlight/SpotlightContainerDecorator';
import SpotlightContainerDecorator from '@enact/spotlight/SpotlightContainerDecorator';
import Spottable from '@enact/spotlight/Spottable';
import { clearThemeDetail } from '../../../../actions/homeActions';
import {
popPanel,
pushPanel,
updatePanel,
} from '../../../../actions/panelActions';
import { popPanel, pushPanel, updatePanel } from '../../../../actions/panelActions';
import { finishVideoPreview } from '../../../../actions/playActions';
import THeader from '../../../../components/THeader/THeader';
import TItemCardNew from '../../../../components/TItemCard/TItemCard.new';
import TVerticalPagenator
from '../../../../components/TVerticalPagenator/TVerticalPagenator';
import TVirtualGridList
from '../../../../components/TVirtualGridList/TVirtualGridList';
import TVerticalPagenator from '../../../../components/TVerticalPagenator/TVerticalPagenator';
import TVirtualGridList from '../../../../components/TVirtualGridList/TVirtualGridList';
import useScrollTo from '../../../../hooks/useScrollTo';
import {
LOG_CONTEXT_NAME,
LOG_MESSAGE_ID,
panel_names,
} from '../../../../utils/Config';
import { LOG_CONTEXT_NAME, LOG_MESSAGE_ID, panel_names } from '../../../../utils/Config';
import { $L } from '../../../../utils/helperMethods';
import css from './YouMayAlsoLike.module.less';
@@ -186,9 +166,11 @@ export default function YouMayAlsoLike({
);
cursorOpen.current.stop();
};
// prdtId가 없는 경우를 대비한 안정적인 key 생성
const itemKey = prdtId ? `${patnrId}-${prdtId}` : `product-${index}`;
return (
<TItemCardNew
key={prdtId}
key={itemKey}
className={css.itemCardNew}
contextName={LOG_CONTEXT_NAME.YOUMAYLIKE}
messageId={LOG_MESSAGE_ID.CONTENTCLICK}

View File

@@ -37,7 +37,7 @@ import {
getMainLiveShowNowProduct,
} from '../../actions/mainActions';
import * as PanelActions from '../../actions/panelActions';
import { updatePanel } from '../../actions/panelActions';
import { updatePanel, focusPanel } from '../../actions/panelActions';
import {
CLEAR_PLAYER_INFO,
getChatLog,
@@ -385,13 +385,14 @@ const MediaPanel = React.forwardRef(
// },[isOnTop, panelInfo])
// MediaPanel.jsx의 라인 313-327 useEffect 수정
// MEDIA 타입은 isOnTop 체크 하지 않음 (패널 구조 복잡도 때문에)
useEffect(() => {
console.log('[MediaPanel] isOnTop:', {
isOnTop,
panelInfo,
});
if (panelInfo && panelInfo.modal) {
if (panelInfo && panelInfo.modal && panelInfo?.shptmBanrTpNm !== 'MEDIA') {
if (!isOnTop) {
console.log('[MediaPanel] Not on top - pausing video');
dispatch(pauseModalMedia());
@@ -875,7 +876,7 @@ const MediaPanel = React.forwardRef(
[dispatch]
);
const onClickBack = useCallback(
const handleClickBack = useCallback(
(ev, isEnd) => {
//modal로부터 Full 전환된 경우 다시 preview 모드로 돌아감.
if (panelInfo.modalContainerId && !panelInfo.modal) {
@@ -904,6 +905,11 @@ const MediaPanel = React.forwardRef(
'[MediaPanel] Back button pressed - returning to modal, focus will be handled by ProductVideo'
);
setTimeout(() => {
console.log('[MediaPanel] focusPanel ');
dispatch(focusPanel('DETAIL_PANEL', 'product-video-player'));
}, 100);
ev?.stopPropagation();
// ev?.preventDefault();
return;
@@ -921,6 +927,7 @@ const MediaPanel = React.forwardRef(
}
ev?.stopPropagation();
// ev?.preventDefault();
return;
}
},
@@ -1036,8 +1043,9 @@ const MediaPanel = React.forwardRef(
}, [topPanel]);
const cannotPlay = useMemo(() => {
return !isOnTop && topPanel?.name === panel_names.MEDIA_PANEL;
}, [topPanel, isOnTop]);
// URL이 있으면 항상 재생 가능 (isOnTop 조건 제거)
return false;
}, []);
const getPlayer = useCallback((ref) => {
videoPlayer.current = ref;
@@ -1678,8 +1686,24 @@ const MediaPanel = React.forwardRef(
return null;
}
return playListInfo && playListInfo[selectedIndex]?.showUrl;
}, [playListInfo, selectedIndex, broadcast]);
// MEDIA 타입일 때: panelInfo.showUrl 사용
if (panelInfo?.shptmBanrTpNm === 'MEDIA') {
console.log('[MediaPanel]-LoadingVideo 📺 MEDIA URL:', {
showUrl: panelInfo?.showUrl?.substring(0, 50),
prdtId: panelInfo?.prdtId,
});
return panelInfo?.showUrl;
}
// 기타 타입: playListInfo 사용
const url = playListInfo && playListInfo[selectedIndex]?.showUrl;
if (url) {
console.log('[MediaPanel]-LoadingVideo 🎬 PlayList URL:', {
url: url.substring(0, 50),
});
}
return url;
}, [playListInfo, selectedIndex, broadcast, panelInfo?.shptmBanrTpNm, panelInfo?.showUrl]);
const isYoutube = useMemo(() => {
if (currentPlayingUrl && currentPlayingUrl.includes('youtu')) {
@@ -1698,11 +1722,17 @@ const MediaPanel = React.forwardRef(
const isReadyToPlay = useMemo(() => {
if (!currentPlayingUrl) {
console.log('[MediaPanel]-LoadingVideo ❌ isReadyToPlay = false (no URL)');
return false;
}
if (!Config.DEBUG_VIDEO_SUBTITLE_TEST && currentSubtitleUrl && !currentSubtitleBlob) {
console.log('[MediaPanel]-LoadingVideo ❌ isReadyToPlay = false (subtitle not loaded):', {
currentSubtitleUrl,
currentSubtitleBlob: !!currentSubtitleBlob,
});
return false;
}
console.log('[MediaPanel]-LoadingVideo ✅ isReadyToPlay = true');
return true;
}, [currentPlayingUrl, currentSubtitleUrl, currentSubtitleBlob, broadcast]);
@@ -2168,7 +2198,7 @@ const MediaPanel = React.forwardRef(
isTabActivated={false}
{...props}
className={containerClassName}
handleCancel={onClickBack}
handleCancel={handleClickBack}
spotlightId={spotlightId}
>
<Container
@@ -2181,15 +2211,32 @@ const MediaPanel = React.forwardRef(
// }
// }}
>
{(() => {
if (isReadyToPlay) {
console.log('[MediaPanel]-LoadingVideo 🎬 VideoPlayer 렌더링:', {
src: currentPlayingUrl?.substring(0, 50),
disabled: panelInfo.modal,
cannotPlay,
isYoutube,
videoComponent:
(typeof window === 'object' && !window.PalmSystem) || isYoutube
? 'TReactPlayer'
: 'Media',
});
} else {
console.log('[MediaPanel]-LoadingVideo 🚫 VideoPlayer 렌더링 스킵됨');
}
return null;
})()}
{isReadyToPlay && (
<div className={css.videoFrame}>
<VideoPlayer
setApiProvider={getPlayer}
disabled={panelInfo.modal}
onEnded={onEnded}
noAutoPlay={cannotPlay}
noAutoPlay={false}
autoCloseTimeout={3000}
onBackButton={onClickBack}
onBackButton={handleClickBack}
spotlightDisabled={false}
isYoutube={isYoutube}
src={currentPlayingUrl}