15 Commits

Author SHA1 Message Date
fd55c04c83 [251217] merge: gitlab develop_si 변경사항 병합
🤖 Generated with Claude Code

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2025-12-17 17:34:36 +09:00
30472bfe17 [251217] fix: MediaPanelOverlayConents분리
🕐 커밋 시간: 2025. 12. 17. 17:24:13

📊 변경 통계:
  • 총 파일: 5개
  • 추가: +528줄
  • 삭제: -70줄

📁 추가된 파일:
  + com.twin.app.shoptime/src/views/PlayerPanel/PlayerOverlay/MediaOverlayContents.module.less

📝 수정된 파일:
  ~ com.twin.app.shoptime/src/components/VideoPlayer/MediaPlayer.module.less
  ~ com.twin.app.shoptime/src/components/VideoPlayer/VideoPlayer.module.less
  ~ com.twin.app.shoptime/src/components/VideoPlayer/VideoPlayer.v3.module.less
  ~ com.twin.app.shoptime/src/views/PlayerPanel/PlayerOverlay/MediaOverlayContents.jsx

🔧 주요 변경 내용:
  • UI 컴포넌트 아키텍처 개선
  • 대규모 기능 개발
2025-12-17 17:24:14 +09:00
junghoon86.park
51d587a1a1 [상세에서의 에너지 라벨 크기 변경]
- 62px -> 70px로 변경.
2025-12-17 16:51:35 +09:00
junghoon86.park
349688092c [테마부분의 에너지 라벨 노출]
- 에너지 라벨 목업데이터 제거 및 받아와서 데이터 노출되도록 처리
 - 에너지 라벨 관련 처리. max-height: 800px로 제한.
2025-12-17 16:36:03 +09:00
d933ca6bb7 [251217] merge: gitlab develop_si 변경사항 병합
🤖 Generated with Claude Code

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2025-12-17 16:25:11 +09:00
e86b56e14e [251217] fix: MediaPanel Spinner제거
🕐 커밋 시간: 2025. 12. 17. 16:23:08

📊 변경 통계:
  • 총 파일: 1개
  • 추가: +2줄
  • 삭제: -2줄

📝 수정된 파일:
  ~ com.twin.app.shoptime/src/components/VideoPlayer/MediaPlayer.v2.jsx

🔧 주요 변경 내용:
  • UI 컴포넌트 아키텍처 개선
  • 코드 정리 및 최적화
2025-12-17 16:23:08 +09:00
junghoon86.park
eee8e73b97 [리뷰 팝업] 리뷰 이미지가 여러개일시에 처리
- 리뷰 이미지가 여러개일때 handlePrevious,handleNext 눌렀을때 이미지부터 변경되도록 수정.
2025-12-17 16:12:29 +09:00
ec76d2cfc9 [251217] fix: VOD 경과시간 표시
🕐 커밋 시간: 2025. 12. 17. 16:09:01

📊 변경 통계:
  • 총 파일: 2개
  • 추가: +18줄
  • 삭제: -205줄

📝 수정된 파일:
  ~ com.twin.app.shoptime/src/components/VideoPlayer/MediaPlayer.jsx
  ~ com.twin.app.shoptime/src/components/VideoPlayer/VideoPlayer.js

🔧 주요 변경 내용:
  • UI 컴포넌트 아키텍처 개선
  • 코드 정리 및 최적화

Performance: 코드 최적화로 성능 개선 기대
2025-12-17 16:09:01 +09:00
a7161b8a80 [251217] fix: LiveChannelContents 동영상 전환시 스크롤
🕐 커밋 시간: 2025. 12. 17. 15:45:48

📊 변경 통계:
  • 총 파일: 1개
  • 추가: +14줄

📝 수정된 파일:
  ~ com.twin.app.shoptime/src/views/PlayerPanel/PlayerTabContents/TabContents/LiveChannelContents.jsx
2025-12-17 15:45:48 +09:00
13e32298a7 [251217] fix: LiveChannelContents TScrollerLiveContents 추가
🕐 커밋 시간: 2025. 12. 17. 15:39:39

📊 변경 통계:
  • 총 파일: 3개
  • 추가: +2줄
  • 삭제: -20줄

📁 추가된 파일:
  + com.twin.app.shoptime/src/views/PlayerPanel/PlayerTabContents/TabContents/TScrollerLiveChannel.jsx
  + com.twin.app.shoptime/src/views/PlayerPanel/PlayerTabContents/TabContents/TScrollerLiveChannel.module.less

📝 수정된 파일:
  ~ com.twin.app.shoptime/src/views/PlayerPanel/PlayerTabContents/TabContents/LiveChannelContents.jsx

🔧 주요 변경 내용:
  • 코드 정리 및 최적화

Performance: 코드 최적화로 성능 개선 기대
2025-12-17 15:39:40 +09:00
5ef0d8afae [251217] merge: gitlab develop_si 변경사항 병합 2025-12-17 14:21:27 +09:00
f6073d78c1 [251217] fix: LiveChannelContents Navigation
🕐 커밋 시간: 2025. 12. 17. 14:17:24

📊 변경 통계:
  • 총 파일: 1개
  • 추가: +100줄
  • 삭제: -9줄

📝 수정된 파일:
  ~ com.twin.app.shoptime/src/views/PlayerPanel/PlayerPanel.jsx

🔧 주요 변경 내용:
  • 소규모 기능 개선
2025-12-17 14:17:24 +09:00
junghoon86.park
0223499e12 [영상]
- 라이브 채널 next부분 관련해서 버튼부분의 배경 색상 제거
2025-12-17 13:46:23 +09:00
3fd3b66cb3 [251217] fix: PlayerPanel activity check 추가
🕐 커밋 시간: 2025. 12. 17. 13:43:33

📊 변경 통계:
  • 총 파일: 1개
  • 추가: +133줄
  • 삭제: -6줄

📝 수정된 파일:
  ~ com.twin.app.shoptime/src/views/PlayerPanel/PlayerPanel.jsx

🔧 주요 변경 내용:
  • 중간 규모 기능 개선
2025-12-17 13:43:33 +09:00
be9b1faeec [251217] fix: 비디오배너 클릭 방어로직추가
🕐 커밋 시간: 2025. 12. 17. 12:11:04

📊 변경 통계:
  • 총 파일: 1개
  • 추가: +10줄

📝 수정된 파일:
  ~ com.twin.app.shoptime/src/views/HomePanel/HomeBanner/RandomUnit.jsx
2025-12-17 12:11:04 +09:00
20 changed files with 1300 additions and 330 deletions

View File

@@ -840,9 +840,11 @@ const VideoPlayerBase = class extends React.Component {
this.state.mediaSliderVisible === nextState.mediaSliderVisible &&
this.state.loading === nextState.loading &&
this.props.loading === nextProps.loading &&
(this.state.currentTime !== nextState.currentTime ||
this.state.proportionPlayed !== nextState.proportionPlayed ||
this.state.sliderTooltipTime !== nextState.sliderTooltipTime)
this.state.currentTime === nextState.currentTime &&
this.state.proportionPlayed === nextState.proportionPlayed &&
this.state.sliderTooltipTime === nextState.sliderTooltipTime &&
this.state.mediaControlsVisible === nextState.mediaControlsVisible &&
this.state.bottomControlsRendered === nextState.bottomControlsRendered
) {
return false;
}
@@ -1279,15 +1281,12 @@ const VideoPlayerBase = class extends React.Component {
sourceUnavailable: true,
proportionPlayed: 0,
proportionLoaded: 0,
bottomControlsRendered: true,
});
if (!this.props.noAutoShowMediaControls) {
if (!this.state.bottomControlsRendered) {
this.renderBottomControl.idle();
} else {
this.showControls();
}
}
};
handlePlay = this.handle(forwardPlay, this.shouldShowMiniFeedback, () => this.play());

View File

@@ -779,6 +779,8 @@
align-items: center;
gap: 12px;
width: 100%;
margin-left: 20px;
margin-right: 20px;
}
.times {

View File

@@ -639,7 +639,7 @@ const MediaPlayerV2 = forwardRef((props, ref) => {
{/* Overlay */}
<Overlay bottomControlsVisible={controlsVisible} onClick={handleVideoClick}>
{/* Loading + Thumbnail */}
{loading && thumbnailUrl && (
{/* {loading && thumbnailUrl && (
<>
<p className={classNames(css.thumbnail, isModal && css.smallThumbnail)}>
<img src={thumbnailUrl} alt="" />
@@ -648,7 +648,7 @@ const MediaPlayerV2 = forwardRef((props, ref) => {
<Loader />
</div>
</>
)}
)} */}
{/* Controls with MediaSlider */}
{controlsVisible && !isModal && (

View File

@@ -855,9 +855,11 @@ const VideoPlayerBase = class extends React.Component {
this.state.mediaSliderVisible === nextState.mediaSliderVisible &&
this.state.loading === nextState.loading &&
this.props.loading === nextProps.loading &&
(this.state.currentTime !== nextState.currentTime ||
this.state.proportionPlayed !== nextState.proportionPlayed ||
this.state.sliderTooltipTime !== nextState.sliderTooltipTime)
this.state.currentTime === nextState.currentTime &&
this.state.proportionPlayed === nextState.proportionPlayed &&
this.state.sliderTooltipTime === nextState.sliderTooltipTime &&
this.state.mediaControlsVisible === nextState.mediaControlsVisible &&
this.state.bottomControlsRendered === nextState.bottomControlsRendered
) {
return false;
}
@@ -1423,15 +1425,12 @@ const VideoPlayerBase = class extends React.Component {
sourceUnavailable: true,
proportionPlayed: 0,
proportionLoaded: 0,
bottomControlsRendered: true,
});
if (!this.props.noAutoShowMediaControls) {
if (!this.state.bottomControlsRendered) {
this.renderBottomControl.idle();
} else {
this.showControls();
}
}
};
handlePlay = this.handle(forwardPlay, this.shouldShowMiniFeedback, () => this.play());
@@ -1636,103 +1635,6 @@ const VideoPlayerBase = class extends React.Component {
updatedState.thumbnailUrl = null;
}
this.setState(updatedState);
// Redux에 비디오 재생 상태 업데이트 (기존 로직 유지)
if (this.props.dispatch) {
// 🔥 onProgress 이벤트는 Redux 업데이트하지 않음 (빈번한 이벤트)
const shouldUpdateRedux = !['onProgress'].includes(ev.type);
if (shouldUpdateRedux) {
const updateState = {
isPlaying: !updatedState.paused,
isPaused: updatedState.paused,
currentTime: updatedState.currentTime,
duration: updatedState.duration,
playbackRate: updatedState.playbackRate,
};
// 가장 중요한 이벤트만 로그
const shouldLogEvent = ['play', 'pause', 'ended'].includes(ev.type);
if (shouldLogEvent) {
dlog('🔄 [PlayerPanel][VideoPlayer] Event-driven Redux update', {
eventType: ev.type,
videoState: updatedState,
updateState,
timestamp: new Date().toISOString(),
});
}
// 🔍 Redux dispatch 확인
dlog('📤 [PlayerPanel][VideoPlayer] Dispatching Redux update', {
eventType: ev.type,
updateState,
hasDispatch: !!this.props.dispatch,
propsVideoPlayState: this.props.videoPlayState,
});
this.props.dispatch(updateVideoPlayState(updateState));
}
} else {
derror('❌ [PlayerPanel][VideoPlayer] No dispatch prop available', {
props: Object.keys(this.props),
hasDispatch: !!this.props.dispatch,
hasVideoPlayState: !!this.props.videoPlayState,
});
}
// 🔹 [강화] 내부 상태와 Redux 상태 동기화
// Redux 상태를 우선적으로 사용하여 내부 상태 일관성 확보
if (this.props.videoPlayState && typeof this.props.videoPlayState === 'object') {
// Redux 상태 디버깅 (최소한의 중요 이벤트만)
if (ev.type === 'play' || ev.type === 'pause') {
dlog('🔍 [PlayerPanel][VideoPlayer] Redux state debug', {
videoPlayState: this.props.videoPlayState,
isPaused: this.props.videoPlayState?.isPaused,
isPlaying: this.props.videoPlayState?.isPlaying,
currentTime: this.props.videoPlayState?.currentTime,
eventType: ev.type,
timestamp: new Date().toISOString(),
});
}
const { currentTime, paused, playbackRate } = this.props.videoPlayState;
// Redux 상태와 현재 내부 상태가 크게 다를 경우 내부 상태 업데이트
const timeDiff = Math.abs(currentTime - this.state.currentTime);
const shouldUpdateTime = timeDiff > 0.5; // 0.5초 이상 차이 시 업데이트
// 빈번한 이벤트는 로그에서 제외
const isFrequentEvent = [
'onProgress',
'onBuffer',
'onBufferEnd',
'onReady',
'onDuration',
'onStart',
].includes(ev.type);
const hasSignificantChange =
shouldUpdateTime || (paused !== this.state.paused && !isFrequentEvent);
// 중요한 상태 변화가 있고 빈번한 이벤트가 아닐 때만 로그
if (hasSignificantChange && !isFrequentEvent) {
dlog('🔄 [PlayerPanel][VideoPlayer] Syncing internal state with Redux', {
timeDiff,
shouldUpdateTime,
pausedDiff: paused !== this.state.paused,
reduxPaused: paused,
internalPaused: this.state.paused,
eventType: ev.type,
timestamp: new Date().toISOString(),
});
}
if (hasSignificantChange) {
this.setState({
currentTime: shouldUpdateTime ? currentTime : this.state.currentTime,
paused: paused !== undefined ? paused : this.state.paused,
playbackRate: playbackRate !== undefined ? playbackRate : this.state.playbackRate,
});
}
}
};
renderBottomControl = new Job(() => {
@@ -1744,7 +1646,6 @@ const VideoPlayerBase = class extends React.Component {
/**
* Returns an object with the current state of the media including `currentTime`, `duration`,
* `paused`, `playbackRate`, `proportionLoaded`, and `proportionPlayed`.
* Redux 상태와 내부 상태를 우선적으로 사용하여 일관성 보장
*
* @function
* @memberof sandstone/VideoPlayer.VideoPlayerBase.prototype
@@ -1752,19 +1653,13 @@ const VideoPlayerBase = class extends React.Component {
* @public
*/
getMediaState = () => {
// Redux 상태를 우선적으로 사용하여 일관성 보장
// Redux 상태가 없으면 내부 상태 사용 (fallback)
const reduxState = this.props.videoPlayState;
return {
currentTime: reduxState?.currentTime ?? this.state.currentTime,
duration: reduxState?.duration ?? this.state.duration,
paused: reduxState?.isPaused ?? this.state.paused,
playbackRate: reduxState?.playbackRate ?? this.video?.playbackRate ?? this.state.playbackRate,
currentTime: this.state.currentTime,
duration: this.state.duration,
paused: this.state.paused,
playbackRate: this.video?.playbackRate,
proportionLoaded: this.state.proportionLoaded,
proportionPlayed: this.state.proportionPlayed,
// Redux 상태 정보도 포함
isPlaying: reduxState?.isPlaying ?? !this.state.paused,
};
};
@@ -1793,16 +1688,7 @@ const VideoPlayerBase = class extends React.Component {
* @public
*/
play = () => {
dlog('🟢 [PlayerPanel][VideoPlayer] play() called', {
currentTime: this.state.currentTime,
duration: this.state.duration,
paused: this.state.paused,
sourceUnavailable: this.state.sourceUnavailable,
prevCommand: this.prevCommand,
});
if (this.state.sourceUnavailable) {
dwarn('⚠️ [PlayerPanel][VideoPlayer] play() aborted - source unavailable');
return;
}
@@ -1814,19 +1700,6 @@ const VideoPlayerBase = class extends React.Component {
this.send('play');
this.announce($L('Play'));
this.startDelayedMiniFeedbackHide(5000);
// Redux 상태 업데이트 - 재생 상태로 변경
if (this.props.dispatch) {
this.props.dispatch(
updateVideoPlayState({
isPlaying: true,
isPaused: false,
currentTime: this.state.currentTime,
duration: this.state.duration,
playbackRate: 1,
})
);
}
};
/**
@@ -1837,16 +1710,7 @@ const VideoPlayerBase = class extends React.Component {
* @public
*/
pause = () => {
dlog('🔴 [VideoPlayer] pause() called', {
currentTime: this.state.currentTime,
duration: this.state.duration,
paused: this.state.paused,
sourceUnavailable: this.state.sourceUnavailable,
prevCommand: this.prevCommand,
});
if (this.state.sourceUnavailable) {
dwarn('⚠️ [VideoPlayer] pause() aborted - source unavailable');
return;
}
@@ -1858,22 +1722,6 @@ const VideoPlayerBase = class extends React.Component {
this.send('pause');
this.announce($L('Pause'));
this.stopDelayedMiniFeedbackHide();
// Redux 상태 업데이트 - 일시정지 상태로 변경
if (this.props.dispatch) {
const pauseState = {
isPlaying: false,
isPaused: true,
currentTime: this.state.currentTime,
duration: this.state.duration,
playbackRate: 1,
};
dlog('📤 [VideoPlayer] Dispatching pause state', pauseState);
this.props.dispatch(updateVideoPlayState(pauseState));
} else {
dwarn('⚠️ [VideoPlayer] No dispatch prop available - Redux state not updated');
}
};
/**
@@ -1885,15 +1733,6 @@ const VideoPlayerBase = class extends React.Component {
* @public
*/
seek = (timeIndex) => {
dlog('⏩ [VideoPlayer] seek() called', {
timeIndex,
currentTime: this.state.currentTime,
duration: this.state.duration,
videoDuration: this.video?.duration,
seekDisabled: this.props.seekDisabled,
sourceUnavailable: this.state.sourceUnavailable,
});
if (this.video) {
if (
!this.props.seekDisabled &&
@@ -1904,34 +1743,9 @@ const VideoPlayerBase = class extends React.Component {
const actualSeekTime =
timeIndex >= this.video.duration ? this.video.duration - 1 : timeIndex;
this.video.currentTime = actualSeekTime;
dlog('⏩ [VideoPlayer] Video seek completed', {
requestedTime: timeIndex,
actualTime: actualSeekTime,
videoDuration: this.video.duration,
});
// Redux 상태 업데이트 - 시간 이동 상태 반영
if (this.props.dispatch) {
const seekState = {
isPlaying: !this.state.paused,
isPaused: this.state.paused,
currentTime: actualSeekTime,
duration: this.state.duration,
playbackRate: this.state.playbackRate,
};
dlog('📤 [VideoPlayer] Dispatching seek state', seekState);
this.props.dispatch(updateVideoPlayState(seekState));
} else {
dwarn('⚠️ [VideoPlayer] No dispatch prop available - Redux state not updated');
}
} else {
derror('❌ [VideoPlayer] seek failed - disabled or source unavailable');
forward('onSeekFailed', {}, this.props);
}
} else {
derror('❌ [VideoPlayer] seek failed - no video element');
}
};

View File

@@ -695,6 +695,7 @@
height: 70px;
width:1800px;
margin-left:60px;
margin-right: 59px;
bottom:92px;
> *:first-child {
text-align: right;

View File

@@ -1,6 +1,7 @@
import React, {
useCallback,
useEffect,
useState,
} from 'react';
import classNames from 'classnames';
@@ -26,17 +27,35 @@ export default function UserReviewDetail({
onNext,
className,
}) {
const [currentImageIndex, setCurrentImageIndex] = useState(0);
// 새로운 리뷰가 로드될 때 이미지 인덱스 초기화
useEffect(() => {
setCurrentImageIndex(0);
}, [currentReview]);
const reviewImages = currentReview?.reviewImageList || [];
const hasMultipleImages = reviewImages.length > 1;
const handlePrevious = useCallback(() => {
if (onPrevious && currentIndex > 0) {
// 이미지가 여러 개이고 현재 이미지가 첫 번째가 아니면 이미지만 변경
if (hasMultipleImages && currentImageIndex > 0) {
setCurrentImageIndex(prev => prev - 1);
} else if (onPrevious && currentIndex > 0) {
// 이미지가 첫 번째이면 이전 리뷰로 이동
onPrevious();
}
}, [onPrevious, currentIndex]);
}, [onPrevious, currentIndex, hasMultipleImages, currentImageIndex]);
const handleNext = useCallback(() => {
if (onNext && currentIndex < totalReviews - 1) {
// 이미지가 여러 개이고 현재 이미지가 마지막이 아니면 이미지만 변경
if (hasMultipleImages && currentImageIndex < reviewImages.length - 1) {
setCurrentImageIndex(prev => prev + 1);
} else if (onNext && currentIndex < totalReviews - 1) {
// 이미지가 마지막이면 다음 리뷰로 이동
onNext();
}
}, [onNext, currentIndex, totalReviews]);
}, [onNext, currentIndex, totalReviews, hasMultipleImages, currentImageIndex, reviewImages.length]);
// 리뷰 데이터가 없을 때 처리
if (!currentReview) {
@@ -47,9 +66,7 @@ export default function UserReviewDetail({
);
}
const reviewImage =
currentReview.reviewImageList && currentReview.reviewImageList[0];
const hasMultipleReviews = totalReviews > 1;
const reviewImage = reviewImages[currentImageIndex];
const formatDate = (dateStr) => {
const [year, month, day] = dateStr.split("-");
@@ -59,7 +76,7 @@ export default function UserReviewDetail({
return (
<>
{/* Left Arrow - 이전 리뷰가 있을 때만 표시 */}
{hasMultipleReviews && currentIndex > 0 && (
{(hasMultipleImages || currentIndex > 0) && (
<SpottableButton
className={css.leftArrow}
onClick={handlePrevious}
@@ -128,7 +145,7 @@ export default function UserReviewDetail({
</div>
</div>
{/* Right Arrow - 다음 리뷰가 있을 때만 표시 */}
{hasMultipleReviews && currentIndex < totalReviews - 1 && (
{(hasMultipleImages || currentIndex < totalReviews - 1) && (
<SpottableButton
className={css.rightArrow}
onClick={handleNext}

View File

@@ -105,7 +105,7 @@
justify-content: flex-start;
align-items: center;
.enrgLbImg {
width:62px;
width:70px;
border:4px solid transparent;
&:focus {
border: 4px solid @PRIMARY_COLOR_RED;

View File

@@ -248,6 +248,7 @@ export default function ThemeContents({
handleItemFocus?.(index);
}}
onMouseEnter={() => Spotlight.focus(spotlightItemId)}
euEnrgLblInfos={euEnrgLblInfos}
/>
);
},

View File

@@ -51,6 +51,7 @@ export default function ThemeItemCard({
spotlightId,
dataSpotlightDefault,
onFocused,
euEnrgLblInfos
}) {
const dispatch = useDispatch();
const [isFocused, setIsFocused] = useState(false);
@@ -211,9 +212,9 @@ export default function ThemeItemCard({
))}
</div>
)} */}
{/* {mockEnergyLabel && mockEnergyLabel.length > 0 && (
{euEnrgLblInfos && euEnrgLblInfos.length > 0 && (
<div className={css.energyLabels}>
{mockEnergyLabel.map((label, labelIndex) => (
{euEnrgLblInfos.map((label, labelIndex) => (
<SpottableTemp
key={labelIndex}
spotlightDisabled={Boolean(!cursorVisible)}
@@ -228,7 +229,7 @@ export default function ThemeItemCard({
</SpottableTemp>
))}
</div>
)} */}
)}
</div>
</SpottableDiv>
{(() => {

View File

@@ -220,3 +220,7 @@
}
}
}
.energyImage {
max-height:800px;
}

View File

@@ -617,6 +617,15 @@ export default function RandomUnit({
// 비디오 클릭
const videoClick = useCallback(() => {
// 🔽 비디오가 다른 배너에서 modal=true로 이미 재생 중이면 클릭 무시
if (playerPanelInfo?.modal === true && currentVideoBannerId && currentVideoBannerId !== spotlightId) {
console.log('[RandomUnit] videoClick 무시: 다른 배너에서 modal=true로 재생 중', {
currentVideoBannerId,
clickedBannerId: spotlightId,
});
return;
}
const lastFocusedTargetId = getContainerId(Spotlight.getCurrent());
const currentSpot = Spotlight.getCurrent();
if (lastFocusedTargetId) {
@@ -674,6 +683,7 @@ export default function RandomUnit({
sendBannerLog,
onBlur,
playerPanelInfo?.modal,
currentVideoBannerId,
dispatch,
handleStartVideo,
]);

View File

@@ -1,9 +1,464 @@
import React from 'react';
import React, {
useCallback,
useEffect,
useMemo,
useRef,
} from 'react';
import PlayerOverlayContents from './PlayerOverlayContents';
import classNames from 'classnames';
import {
useDispatch,
useSelector,
} from 'react-redux';
function MediaOverlayContents(props) {
return <PlayerOverlayContents {...props} forceShowMediaOverlay />;
import Spotlight from '@enact/spotlight';
import SpotlightContainerDecorator
from '@enact/spotlight/SpotlightContainerDecorator';
import Spottable from '@enact/spotlight/Spottable';
import Marquee from '@enact/ui/Marquee';
import defaultLogoImg
from '../../../../assets/images/ic-tab-partners-default@3x.png';
import { setShowPopup } from '../../../actions/commonActions';
import CustomImage from '../../../components/CustomImage/CustomImage';
import { ACTIVE_POPUP } from '../../../utils/Config';
import { SpotlightIds } from '../../../utils/SpotlightIds';
import PlayerTabButton from '../PlayerTabContents/TabButton/PlayerTabButton';
import css from './MediaOverlayContents.module.less';
const SpottableBtn = Spottable('button');
const Container = SpotlightContainerDecorator({ enterTo: 'last-focused' }, 'div');
function MediaOverlayContents({
type,
onClick,
panelInfo,
disclaimer,
playListInfo,
captionEnable,
selectedIndex,
setIsSubtitleActive,
videoVerticalVisible,
sideContentsVisible,
setSideContentsVisible,
belowContentsVisible,
handleIndicatorUpClick,
handleIndicatorDownClick,
tabContainerVersion,
tabIndexV2,
}) {
const cntry_cd = useSelector((state) => state.common.httpHeader?.cntry_cd);
const dispatch = useDispatch();
const onClickBack = (ev) => {
// TabContainerV2가 표시된 상태에서 백버튼 클릭 시 이벤트 버블링 방지
// (Overlay의 onClick으로 전파되어 toggleControls()가 호출되는 것을 막음)
if (tabContainerVersion === 2 && belowContentsVisible) {
ev.stopPropagation();
}
if (onClick) {
onClick(ev);
}
};
const backBtnRef = useRef(null);
useEffect(() => {
if (type === 'MEDIA' && !panelInfo.modal && backBtnRef.current) {
Spotlight.focus(SpotlightIds.PLAYER_BACK_BUTTON);
}
}, [type, panelInfo.modal, backBtnRef]);
const handleSubtitleOnClick = useCallback(() => {
if (!captionEnable) {
return dispatch(setShowPopup(ACTIVE_POPUP.alertPopup));
}
setIsSubtitleActive((prev) => !prev);
}, [dispatch, captionEnable, setIsSubtitleActive]);
const patncLogoPath = useMemo(() => {
let logo = playListInfo[selectedIndex]?.patncLogoPath;
if (type === 'MEDIA') {
logo = panelInfo?.patncLogoPath;
}
return logo;
}, [playListInfo, selectedIndex, panelInfo, type]);
const partnerName = useMemo(() => {
let name = playListInfo[selectedIndex]?.patncNm;
if (type === 'MEDIA') {
name = panelInfo?.patncNm;
}
return name;
}, [playListInfo, selectedIndex, panelInfo, type]);
const showName = useMemo(() => {
let name = playListInfo[selectedIndex]?.showNm;
if (type === 'MEDIA') {
name = panelInfo?.showNm;
}
return name ? name.replace(/<br\s*\/?>/gi, ' ') : '';
}, [playListInfo, selectedIndex, panelInfo, type]);
const onSpotlightMoveTabButton = useCallback((e) => {
e.stopPropagation();
e.preventDefault();
Spotlight.focus(SpotlightIds.PLAYER_TAB_BUTTON);
}, []);
const onSpotlightMoveMediaButton = useCallback(
(e) => {
e.stopPropagation();
// TabContainerV2의 tabIndex=2일 때 하단 버튼들로 포커스 이동
if (tabContainerVersion === 2 && tabIndexV2 === 2) {
if (Spotlight.focus('live-channel-next-button')) return;
if (Spotlight.focus('below-tab-shop-now-button')) return;
}
if (type === 'LIVE') {
return Spotlight.focus('videoIndicator-down-button');
}
return Spotlight.focus(SpotlightIds.PLAYER_PLAY_BUTTON);
},
[type, tabContainerVersion, tabIndexV2]
);
const onSpotlightMoveSubtitleButton = useCallback(
() => {
// 1. 먼저 자막 버튼으로 포커스 시도
if (Spotlight.focus('player-subtitlebutton')) {
return true;
}
// TabContainerV2의 tabIndex=2일 때 TabContainerV2 버튼들로 포커스 이동
if (tabContainerVersion === 2 && tabIndexV2 === 2) {
let focusSuccessful = false;
// 먼저 LiveChannelNext 버튼으로 시도
if (Spotlight.focus('live-channel-next-button')) {
focusSuccessful = true;
}
// 실패하면 ShopNowButton으로 시도
else if (Spotlight.focus('below-tab-shop-now-button')) {
focusSuccessful = true;
}
if (focusSuccessful) {
return;
}
}
// 기본 동작: 자막 버튼으로 포커스
return Spotlight.focus('player-subtitlebutton');
},
[tabContainerVersion, tabIndexV2]
);
const onSpotlightMoveSlider = useCallback(
(e) => {
if (type === 'VOD') {
e.stopPropagation();
Spotlight.focus(SpotlightIds.PLAYER_SLIDER);
}
},
[type]
);
const onSpotlightMoveSideTab = useCallback((e) => {
e.stopPropagation();
e.preventDefault();
Spotlight.focus('tab-0');
}, []);
const onSpotlightMoveBelowTab = useCallback(
(e) => {
e.stopPropagation();
e.preventDefault();
console.log(`[onSpotlightMoveBelowTab] tabIndexV2: ${tabIndexV2}`);
// tabIndexV2에 따라 다른 버튼으로 포커스 이동
if (tabIndexV2 === 0) {
// ShopNow 탭: Close 버튼으로
// Spotlight.focus('below-tab-close-button');
const result = Spotlight.focus('shownow_close_button');
console.log(`[onSpotlightMoveBelowTab] tabIndexV2=0, focus result:`, result);
} else if (tabIndexV2 === 1) {
// LIVE CHANNEL 탭: LIVE CHANNEL 버튼으로
const result = Spotlight.focus('below-tab-live-channel-button');
console.log(`[onSpotlightMoveBelowTab] tabIndexV2=1, focus result:`, result);
} else if (tabIndexV2 === 2) {
// ShopNowButton: ShopNowButton으로
const result = Spotlight.focus('below-tab-shop-now-button');
console.log(`[onSpotlightMoveBelowTab] tabIndexV2=2, focus result:`, result);
}
},
[tabIndexV2]
);
// Back Button arrow down 전용 핸들러 - tabIndex에 따라 다른 포커스
const handleBackButtonDown = useCallback(
(e) => {
e.stopPropagation();
e.preventDefault();
if (tabContainerVersion === 2 && belowContentsVisible) {
if (tabIndexV2 === 0) {
// tabIndexV2가 0일 때 ShopNow 닫기 버튼으로 포커스
const result = Spotlight.focus('shownow_close_button');
} else if (tabIndexV2 === 1) {
// tabIndexV2가 1일 때 below-tab-live-channel-button으로 포커스
Spotlight.focus('below-tab-live-channel-button');
} else if (tabIndexV2 === 2) {
// tabIndexV2가 2일 때 LiveChannelNext로 포커스
Spotlight.focus('live-channel-next-button');
} else {
// 그 외에는 기존 로직 사용
onSpotlightMoveMediaButton(e);
}
} else {
onSpotlightMoveMediaButton(e);
}
},
[tabContainerVersion, belowContentsVisible, tabIndexV2, onSpotlightMoveMediaButton]
);
const onSpotlightMoveBackButton = useCallback(() => {
return Spotlight.focus(SpotlightIds.PLAYER_BACK_BUTTON);
}, []);
const handleOverlayKeyDownCapture = useCallback(
(ev) => {
const currentId = Spotlight.getCurrent()?.getAttribute('data-spotlight-id');
if (ev.keyCode === 40 && currentId === SpotlightIds.PLAYER_BACK_BUTTON) {
ev.preventDefault();
ev.stopPropagation();
return handleBackButtonDown(ev);
}
if (ev.keyCode === 39 && currentId === SpotlightIds.PLAYER_BACK_BUTTON) {
ev.preventDefault();
ev.stopPropagation();
return onSpotlightMoveSubtitleButton(ev);
}
if (ev.keyCode === 37 && currentId === SpotlightIds.PLAYER_SUBTITLE_BUTTON) {
ev.preventDefault();
ev.stopPropagation();
return onSpotlightMoveBackButton(ev);
}
if (ev.keyCode === 38 && currentId === SpotlightIds.PLAYER_PLAY_BUTTON) {
ev.preventDefault();
ev.stopPropagation();
return onSpotlightMoveBackButton();
}
},
[onSpotlightMoveBackButton, onSpotlightMoveMediaButton, onSpotlightMoveSubtitleButton, handleBackButtonDown]
);
const currentSideButtonStatus = useMemo(() => {
if (
!panelInfo?.modal &&
!sideContentsVisible &&
tabContainerVersion === 1
) {
return true;
}
return false;
}, [panelInfo, sideContentsVisible, tabContainerVersion]);
const noLiveContentsVisible = useMemo(() => {
if (!Array.isArray(playListInfo) || playListInfo.length === 0) {
return false;
}
const noShowIdCount = playListInfo.filter((item) => !item.showId).length;
if (playListInfo.length - 1 === noShowIdCount) {
return false;
}
return true;
}, [playListInfo]);
return (
<>
<Container className={css.overlayContainer} onKeyDownCapture={handleOverlayKeyDownCapture}>
{/* 251118 임시로 unvisible */}
{/* {playListInfo.length > 1 && noLiveContentsVisible && (
<>
<div className={css.indicatorUpButton}>
<SpottableBtn
onClick={handleIndicatorUpClick}
spotlightId="videoIndicator-up-button"
onSpotlightRight={
videoVerticalVisible
? onSpotlightMoveSideTab
: tabContainerVersion === 1
? onSpotlightMoveTabButton
: undefined
}
onSpotlightDown={
tabContainerVersion === 2 && belowContentsVisible
? onSpotlightMoveBelowTab
: onSpotlightMoveSlider
}
aria-label="Previous channel"
/>
</div>
<div className={css.indicatorDownButton}>
<SpottableBtn
onClick={handleIndicatorDownClick}
spotlightId="videoIndicator-down-button"
onSpotlightLeft={onSpotlightMoveSlider}
onSpotlightUp={onSpotlightMoveSlider}
onSpotlightRight={
videoVerticalVisible
? onSpotlightMoveSideTab
: tabContainerVersion === 1
? onSpotlightMoveTabButton
: undefined
}
onSpotlightDown={
tabContainerVersion === 2 && belowContentsVisible
? onSpotlightMoveBelowTab
: undefined
}
aria-label="Next channel"
/>
</div>
</>
)} */}
{currentSideButtonStatus && !videoVerticalVisible && (
<PlayerTabButton
setSideContentsVisible={setSideContentsVisible}
sideContentsVisible={sideContentsVisible}
onSpotlightLeft={
playListInfo.length < 2 && onSpotlightMoveBackButton
}
videoType={type}
/>
)}
{cntry_cd === 'US' && (
<div className={css.videoButtonContainer}>
<SpottableBtn
className={classNames(
css.subtitleButton,
videoVerticalVisible && css.videoVericalSubtitleButton
)}
onClick={handleSubtitleOnClick}
spotlightId="player-subtitlebutton"
onSpotlightUp={(e) => {
e.stopPropagation();
e.preventDefault();
// tabIndexV2가 2일 때만 ShopNowButton으로 포커스
if (tabContainerVersion === 2 && tabIndexV2 === 2) {
Spotlight.focus('below-tab-shop-now-button');
} else {
onSpotlightMoveBackButton();
}
}}
onSpotlightLeft={(e) => {
e.stopPropagation();
e.preventDefault();
// tabIndexV2가 2일 때만 LiveChannelNext로 포커스
if (tabContainerVersion === 2 && tabIndexV2 === 2) {
Spotlight.focus('live-channel-next-button');
}
}}
onSpotlightRight={(e) => {
e.stopPropagation();
e.preventDefault();
// tabIndexV2가 2일 때만 LiveChannelNext로 포커스
if (tabContainerVersion === 2 && tabIndexV2 === 2) {
Spotlight.focus('below-tab-shop-now-button');
}
}}
onSpotlightDown={(e) => {
e.stopPropagation();
e.preventDefault();
// tabIndexV2가 2일 때만 ShopNowButton으로 포커스
if (tabContainerVersion === 2 && tabIndexV2 === 2) {
Spotlight.focus('live-channel-next-button');
}
}}
aria-label="Caption"
/>
</div>
)}
<div className={css.overlayHeader}>
<SpottableBtn
onClick={onClickBack}
className={css.backIcon}
spotlightId="player-back-button"
onSpotlightDown={handleBackButtonDown}
onSpotlightRight={(e) => {
e.stopPropagation();
e.preventDefault();
// tabIndexV2가 2일 때만 ShopNowButton으로 포커스
if (tabContainerVersion === 2 && tabIndexV2 === 2) {
Spotlight.focus('below-tab-shop-now-button');
}
}}
onSpotlightUp={onSpotlightMoveSubtitleButton}
aria-label="Video Player Close"
ref={backBtnRef}
/>
<div
className={classNames(type === 'LIVE' && css.liveIcon)}
aria-label={type === 'LIVE' && 'Live Icon'}
/>
{partnerName && (
<CustomImage
src={patncLogoPath}
fallbackSrc={defaultLogoImg}
alt={partnerName}
aria-label={partnerName}
/>
)}
<h2 className={css.patnerName}>{partnerName}</h2>
{!panelInfo?.modal && (
<Marquee
className={classNames(css.title, videoVerticalVisible && css.videoVerticalMarquee)}
marqueeOn="render"
>
{showName}
</Marquee>
)}
</div>
</Container>
{type === 'VOD' && disclaimer && (
<div className={css.disclaimer}>
<span className={css.icon} />
<h3 aria-label={disclaimer}>{disclaimer}</h3>
</div>
)}
</>
);
}
export default MediaOverlayContents;
const propsAreEqual = (prev, next) => {
return (
prev.type === next.type &&
prev.panelInfo?.showId === next.panelInfo?.showId &&
prev.disclaimer === next.disclaimer &&
prev.playListInfo === next.playListInfo &&
prev.captionEnable === next.captionEnable &&
prev.selectedIndex === next.selectedIndex &&
prev.videoVerticalVisible === next.videoVerticalVisible &&
prev.sideContentsVisible === next.sideContentsVisible &&
prev.belowContentsVisible === next.belowContentsVisible &&
prev.tabContainerVersion === next.tabContainerVersion &&
prev.tabIndexV2 === next.tabIndexV2
);
};
export default React.memo(MediaOverlayContents, propsAreEqual);

View File

@@ -0,0 +1,155 @@
@import "../../../style/CommonStyle.module.less";
@import "../../../style/utils.module.less";
.overlayContainer {
.position(@position: absolute, @top: 77px, @right: 0, @bottom: 0, @left: 60px);
display: flex;
z-index: 5;
.videoButtonContainer {
.subtitleButton {
width: 60px;
height: 60px;
background-image: url("../../../../assets/images/btn/btn-video-cc-nor@3x.png");
background-size: cover;
position: absolute;
right: 300px;
top: 680px;
z-index: 10;
&.videoVericalSubtitleButton {
position: absolute;
right: 680px;
top: 850px;
}
&:focus {
background-color: rgba(199, 8, 80, 0.5);
border-radius: 50%;
}
}
}
.indicatorUpButton {
position: absolute;
top: -77px;
left: 880px;
z-index: 10;
.size(@w: 144px, @h: 48px);
> button {
.size(@w: 144px, @h: 48px);
background-position: center center;
background-repeat: no-repeat;
background-image: url("../../../../assets/images/btn/btn-wh-arrow-top-nor.svg");
&:focus {
background-color: @PRIMARY_COLOR_RED;
border-radius: 0 0 4px 4px;
}
}
}
.indicatorDownButton {
position: absolute;
bottom: -986px;
left: 880px;
z-index: 10;
.size(@w: 144px, @h: 48px);
> button {
.size(@w: 144px, @h: 48px);
background-position: center center;
background-repeat: no-repeat;
background-image: url("../../../../assets/images/btn/btn-wh-arrow-down-nor.svg");
&:focus {
background-color: @PRIMARY_COLOR_RED;
border-radius: 4px 4px 0 0;
}
}
}
.overlayHeader {
display: flex;
width: 100%;
height: auto;
.backIcon {
.size(@w: 60px, @h: 60px);
background-size: 60px 60px;
background-repeat: no-repeat;
background-image: url("../../../../assets/images/btn/btn-60-wh-back-nor@3x.png");
&:focus {
background-image: url("../../../../assets/images/btn/btn-60-wh-back-foc@3x.png");
}
}
.liveIcon {
.size(@w: 108px, @h: 48px);
margin: 6px 0 6px 30px;
background-size: 108px 48px;
background-repeat: no-repeat;
background-image: url("../../../../assets/images/tag-liveshow.png");
vertical-align: top;
}
> img {
.size(@w: auto, @h: 60px);
margin-left: 30px;
}
.patnerName {
font-size: 44px;
font-weight: bold;
color: #fcfcfc;
margin-left: 14px;
padding-top: 10px;
}
.title {
width: 1200px;
align-items: center;
font-size: 44px;
color: #fcfcfc;
margin-left: 35px;
> div {
> div {
line-height: 1.2;
}
}
&.videoVerticalTitle {
.size(@w: 950px , @h: 60px);
> div {
> div {
padding-top: 10px;
.size(@w: 940px , @h: 60px);
}
}
}
// .marquee {
// &.videoVerticalMarquee {
// width: 950px;
// }
// }
}
}
}
.disclaimer {
.size(@w: 1800px , @h: 54px);
display: flex;
background-color: rgba(0, 0, 0, 0.5);
position: absolute;
top: 140px;
left: 60px;
align-items: center;
> span {
.size(@w: 18px , @h: 18px);
background-image: url("../../../../assets/images/icons/ic-alert-20@3x.png");
background-position: center;
background-size: cover;
margin: 0 12px 0 20px;
}
> h3 {
color: #ffffff;
font-size: 20px;
}
}

View File

@@ -1248,12 +1248,12 @@ const PlayerPanel = ({ isTabActivated, panelInfo, isOnTop, spotlightId, ...props
return () => {
// 패널이 2개 존재할때만 popPanel 진행
// 현재 스택의 top이 PlayerPanel일 때만 pop 수행 (다른 패널이 올라온 상태에서 오작동 방지)
console.log('[PP-TRACE] cleanup start', {
modal: panelInfo.modal,
isOnTop,
topPanel: panels[panels.length - 1]?.name,
stack: panels.map((p) => p.name),
});
// console.log('[PP-TRACE] cleanup start', {
// modal: panelInfo.modal,
// isOnTop,
// topPanel: panels[panels.length - 1]?.name,
// stack: panels.map((p) => p.name),
// });
// 🔽 [251221] PlayerPanel unmount 시 DeepLink 플래그 리셋
dispatch(
@@ -1272,7 +1272,7 @@ const PlayerPanel = ({ isTabActivated, panelInfo, isOnTop, spotlightId, ...props
topPanelName === panel_names.PLAYER_PANEL &&
panels.length === 1 // 다른 패널 존재 시 pop 금지 (DetailPanel 제거 방지)
) {
console.log('[PP-TRACE] popPanel - useEffect cleanup (top is PlayerPanel)');
// console.log('[PP-TRACE] popPanel - useEffect cleanup (top is PlayerPanel)');
dispatch(PanelActions.popPanel());
} else {
Spotlight.focus('tbody');
@@ -1845,6 +1845,7 @@ const PlayerPanel = ({ isTabActivated, panelInfo, isOnTop, spotlightId, ...props
if (watchIntervalLive.current) clearInterval(watchIntervalLive.current);
if (watchIntervalVod.current) clearInterval(watchIntervalVod.current);
if (watchIntervalMedia.current) clearInterval(watchIntervalMedia.current);
if (activityCheckIntervalRef.current) clearInterval(activityCheckIntervalRef.current);
};
}, []);
@@ -2345,7 +2346,7 @@ const PlayerPanel = ({ isTabActivated, panelInfo, isOnTop, spotlightId, ...props
setPrevChannelIndex(selectedIndex);
}
setSideContentsVisible(true);
}, [dispatch, playListInfo, selectedIndex, sideContentsVisible, initialEnter]);
}, [dispatch, playListInfo, selectedIndex, sideContentsVisible, initialEnter, tabContainerVersion, tabIndexV2]);
const handleIndicatorUpClick = useCallback(() => {
if (!initialEnter) {
@@ -2393,7 +2394,7 @@ const PlayerPanel = ({ isTabActivated, panelInfo, isOnTop, spotlightId, ...props
setPrevChannelIndex(selectedIndex);
}
setSideContentsVisible(true);
}, [dispatch, playListInfo, selectedIndex, sideContentsVisible, initialEnter]);
}, [dispatch, playListInfo, selectedIndex, sideContentsVisible, initialEnter, tabContainerVersion, tabIndexV2]);
useEffect(() => {
if (panelInfo.shptmBanrTpNm === 'VOD' && panelInfo.patnrId && panelInfo.showId) {
@@ -2535,14 +2536,105 @@ const PlayerPanel = ({ isTabActivated, panelInfo, isOnTop, spotlightId, ...props
);
const onKeyDown = (ev) => {
if (ev.keyCode === 34) {
// tabIndex === 1 (LiveChannelContents 표시)이고 비디오 배너에 포커스가 있는 경우
const currentFocused = Spotlight.getCurrent();
const spotlightId = currentFocused?.getAttribute('data-spotlight-id');
const isVideoItemFocused = spotlightId?.startsWith('tabChannel-video');
// LiveChannelContents의 비디오 배너에 포커스가 있는 경우: PageUp/PageDown을 좌우 이동으로 변환
if (tabIndexV2 === 1 && isVideoItemFocused) {
// DOM에서 실제로 렌더링된 모든 비디오 배너 찾기 (가상화 대응)
const allVideoBanners = Array.from(
document.querySelectorAll('[data-spotlight-id^="tabChannel-video-"]')
);
if (allVideoBanners.length > 0) {
// 현재 포커스된 배너의 인덱스 찾기
const currentBannerIndex = allVideoBanners.findIndex(
(el) => el === currentFocused
);
if (currentBannerIndex !== -1) {
if (ev.keyCode === 34) { // PageDown -> 오른쪽 배너로 포커스 이동
ev.stopPropagation();
ev.preventDefault();
// DOM에 렌더링된 다음 배너로 이동 (마지막이면 무시 또는 첫 번째로)
if (currentBannerIndex < allVideoBanners.length - 1) {
// 다음 배너가 DOM에 있으면 이동
const nextBanner = allVideoBanners[currentBannerIndex + 1];
const nextSpotlightId = nextBanner.getAttribute('data-spotlight-id');
dlog('[PlayerPanel] 🎯 PageDown (비디오 배너) -> 오른쪽으로 이동', {
current: spotlightId,
next: nextSpotlightId,
currentBannerIndex,
totalVisibleBanners: allVideoBanners.length,
});
Spotlight.focus(nextSpotlightId);
} else {
// 마지막 배너면 첫 번째로 이동 시도 (DOM에 있으면)
const firstBanner = allVideoBanners[0];
const firstSpotlightId = firstBanner.getAttribute('data-spotlight-id');
dlog('[PlayerPanel] 🎯 PageDown (마지막 배너) -> 첫 번째 배너로 이동 시도', {
current: spotlightId,
next: firstSpotlightId,
isWrapAround: true,
});
Spotlight.focus(firstSpotlightId);
}
return;
} else if (ev.keyCode === 33) { // PageUp -> 왼쪽 배너로 포커스 이동
ev.stopPropagation();
ev.preventDefault();
// DOM에 렌더링된 이전 배너로 이동 (첫 번째면 무시 또는 마지막으로)
if (currentBannerIndex > 0) {
// 이전 배너가 DOM에 있으면 이동
const prevBanner = allVideoBanners[currentBannerIndex - 1];
const prevSpotlightId = prevBanner.getAttribute('data-spotlight-id');
dlog('[PlayerPanel] 🎯 PageUp (비디오 배너) -> 왼쪽으로 이동', {
current: spotlightId,
prev: prevSpotlightId,
currentBannerIndex,
totalVisibleBanners: allVideoBanners.length,
});
Spotlight.focus(prevSpotlightId);
} else {
// 첫 번째 배너면 마지막으로 이동 시도 (DOM에 있으면)
const lastBanner = allVideoBanners[allVideoBanners.length - 1];
const lastSpotlightId = lastBanner.getAttribute('data-spotlight-id');
dlog('[PlayerPanel] 🎯 PageUp (첫 번째 배너) -> 마지막 배너로 이동 시도', {
current: spotlightId,
prev: lastSpotlightId,
isWrapAround: true,
});
Spotlight.focus(lastSpotlightId);
}
return;
}
}
}
}
// 기존 로직: LiveChannelButton 또는 다른 경우에는 상/하 이동
if (ev.keyCode === 34) { // PageDown
handleIndicatorDownClick();
ev.stopPropagation();
ev.preventDefault();
} else if (ev.keyCode === 33) {
dlog('[PlayerPanel] 📺 PageDown (버튼 또는 다른 경우) -> 다음 비디오');
} else if (ev.keyCode === 33) { // PageUp
handleIndicatorUpClick();
ev.stopPropagation();
ev.preventDefault();
dlog('[PlayerPanel] 📺 PageUp (버튼 또는 다른 경우) -> 이전 비디오');
}
};
@@ -2553,6 +2645,11 @@ const PlayerPanel = ({ isTabActivated, panelInfo, isOnTop, spotlightId, ...props
const timerIdTabAutoAdvance = useRef(null);
const prevTabIndexV2 = useRef(null);
// Activity Check for tabIndex auto-advance
const lastActivityTimeRef = useRef(Date.now());
const activityCheckIntervalRef = useRef(null);
const ACTIVITY_TIMEOUT = 1000; // 1초 동안 활동이 없으면 타이머 진행
const showSideContents = useMemo(() => {
return (
sideContentsVisible &&
@@ -2664,17 +2761,62 @@ const PlayerPanel = ({ isTabActivated, panelInfo, isOnTop, spotlightId, ...props
timerIdTabAutoAdvance.current = null;
}, []);
// Activity 감지 함수
const onActivityDetected = useCallback(() => {
lastActivityTimeRef.current = Date.now();
dlog('[PlayerPanel] 🎯 Activity detected - timer will be delayed', {
timestamp: new Date().toISOString(),
});
}, []);
// Activity 여부를 확인하는 함수 (1초 타임아웃 체크)
const isInactive = useCallback(() => {
const now = Date.now();
const timeSinceLastActivity = now - lastActivityTimeRef.current;
return timeSinceLastActivity > ACTIVITY_TIMEOUT;
}, []);
const resetTimerTabAutoAdvance = useCallback(
(timeout) => {
if (timerIdTabAutoAdvance.current) {
clearTimerTabAutoAdvance();
}
timerIdTabAutoAdvance.current = setTimeout(() => {
// Activity check interval 설정 (매 100ms마다 체크)
if (activityCheckIntervalRef.current) {
clearInterval(activityCheckIntervalRef.current);
}
let elapsedTime = 0;
activityCheckIntervalRef.current = setInterval(() => {
// 활동이 없을 때만 경과 시간 증가
if (isInactive()) {
elapsedTime += 100;
dlog('[PlayerPanel] ⏱️ TabIndex auto-advance: inactive', {
elapsedTime,
requiredTime: timeout,
});
// 필요한 시간만큼 경과했으면 타이머 실행
if (elapsedTime >= timeout) {
dlog('[PlayerPanel] ✅ TabIndex auto-advance executing - setTabIndexV2(2)', {
totalElapsed: elapsedTime,
timeout,
});
clearInterval(activityCheckIntervalRef.current);
setTabIndexV2(2);
}, timeout);
}
} else {
// 활동이 감지되면 경과 시간 리셋
dlog('[PlayerPanel] 🔄 Activity detected - resetting elapsed time', {
previousElapsed: elapsedTime,
});
elapsedTime = 0;
}
}, 100);
},
[clearTimerTabAutoAdvance]
[clearTimerTabAutoAdvance, isInactive]
);
// Redux로 오버레이 숨김
@@ -2699,6 +2841,10 @@ const PlayerPanel = ({ isTabActivated, panelInfo, isOnTop, spotlightId, ...props
if (timerIdTabAutoAdvance.current) {
clearTimerTabAutoAdvance();
}
if (activityCheckIntervalRef.current) {
clearInterval(activityCheckIntervalRef.current);
activityCheckIntervalRef.current = null;
}
dispatch(resetPlayerOverlays());
}
@@ -2913,6 +3059,53 @@ const PlayerPanel = ({ isTabActivated, panelInfo, isOnTop, spotlightId, ...props
panelInfo?.modal,
]);
// PageUp/PageDown으로 비디오 변경 시 현재 재생 배너로 포커스 이동
useEffect(() => {
if (tabContainerVersion === 2 &&
tabIndexV2 === 1 &&
panelInfo?.isIndicatorByClick &&
selectedIndex !== null &&
selectedIndex >= 0) {
dlog('[PlayerPanel] 🎯 PageUp/PageDown 후 포커스 이동 준비', {
selectedIndex,
tabContainerVersion,
tabIndexV2,
isIndicatorByClick: panelInfo.isIndicatorByClick
});
const bannerSpotlightId = `banner${selectedIndex}`;
setTimeout(() => {
dlog('[PlayerPanel] 🔍 포커스 이동 시도:', bannerSpotlightId);
const bannerElement = document.querySelector(`[data-spotlight-id="${bannerSpotlightId}"]`);
if (bannerElement) {
dlog('[PlayerPanel] ✅ 배너 요소 찾음, 포커스 이동 실행');
Spotlight.focus(bannerElement);
} else {
dlog('[PlayerPanel] ⚠️ 배너 요소 찾지 못함:', bannerSpotlightId);
// 모든 배너 요소 목록 출력
const allBanners = document.querySelectorAll('[data-spotlight-id^="banner"]');
dlog('[PlayerPanel] 🔍 사용 가능한 배너 목록:',
Array.from(allBanners).map(el => el.getAttribute('data-spotlight-id'))
);
}
// 플래그 리셋
dispatch(
updatePanel({
name: panel_names.PLAYER_PANEL,
panelInfo: {
isIndicatorByClick: false
},
})
);
}, 200); // DOM 업데이트 대기
}
}, [selectedIndex, tabContainerVersion, tabIndexV2, panelInfo?.isIndicatorByClick, dispatch]);
// TabIndex 1 자동 다음 단계로 이동
useEffect(() => {
// tabIndex === 1일 때만 실행
@@ -2939,6 +3132,31 @@ const PlayerPanel = ({ isTabActivated, panelInfo, isOnTop, spotlightId, ...props
clearTimerTabAutoAdvance,
]);
// Activity detection for tabIndex auto-advance (mousemove, keydown, click)
useEffect(() => {
// tabIndex === 1일 때만 Activity 감지 활성화
if (tabIndexV2 !== 1 || !belowContentsVisible) {
return;
}
dlog('[PlayerPanel] 🎙️ Activity listener registered for tabIndex=1');
const handleMouseMove = onActivityDetected;
const handleKeyDown = onActivityDetected;
const handleClick = onActivityDetected;
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('keydown', handleKeyDown);
document.addEventListener('click', handleClick);
return () => {
dlog('[PlayerPanel] 🎙️ Activity listener unregistered');
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('keydown', handleKeyDown);
document.removeEventListener('click', handleClick);
};
}, [tabIndexV2, belowContentsVisible, onActivityDetected]);
useLayoutEffect(() => {
const videoContainer = document.querySelector(`.${css.videoContainer}`);

View File

@@ -5,9 +5,8 @@ import { useDispatch } from 'react-redux';
import Spotlight from '@enact/spotlight';
import { sendLogTotalRecommend } from '../../../../actions/logActions';
// <<<<<<< HEAD
import { updatePanel } from '../../../../actions/panelActions';
import TVirtualGridList from '../../../../components/TVirtualGridList/TVirtualGridList';
import TScrollerLiveChannel from './TScrollerLiveChannel';
import {
LOG_CONTEXT_NAME,
LOG_MENU,
@@ -20,26 +19,11 @@ import ListEmptyContents from '../TabContents/ListEmptyContents/ListEmptyContent
import css from './LiveChannelContents.module.less';
import cssV2 from './LiveChannelContents.v2.module.less';
// =======
// import { updatePanel } from "../../../../actions/panelActions";
// import TVirtualGridList from "../../../../components/TVirtualGridList/TVirtualGridList";
// import {
// LOG_CONTEXT_NAME,
// LOG_MENU,
// LOG_MESSAGE_ID,
// panel_names,
// } from "../../../../utils/Config";
// import { $L } from "../../../../utils/helperMethods";
// import PlayerItemCard, { TYPES } from "../../PlayerItemCard/PlayerItemCard";
// import ListEmptyContents from "../TabContents/ListEmptyContents/ListEmptyContents";
// import css from "./LiveChannelContents.module.less";
// import { sendLogTotalRecommend } from "../../../../actions/logActions";
// >>>>>>> gitlab/develop
export default function LiveChannelContents({
liveInfos,
currentTime,
setSelectedIndex,
selectedIndex,
videoVerticalVisible,
currentVideoShowId,
tabIndex,
@@ -83,6 +67,19 @@ export default function LiveChannelContents({
}
}, [isFilteredByPatnr19]);
// currentVideoShowId 변경 시 해당 배너가 보이도록 스크롤
// (LiveChannelButton에서 PageUp/PageDown으로 동영상 변경 시)
// currentVideoShowId 기반으로 스크롤하면 포커스 이동 없이 배너만 화면에 보임
useEffect(() => {
if (currentVideoShowId && liveInfos && liveInfos.length > 0 && scrollToRef.current) {
// currentVideoShowId와 일치하는 배너의 인덱스 찾기
const index = liveInfos.findIndex((item) => item.showId === currentVideoShowId);
if (index !== -1) {
scrollToRef.current({ index, animate: true, focus: false });
}
}
}, [currentVideoShowId, liveInfos]);
const renderItem = useCallback(
({ index, ...rest }) => {
const {
@@ -222,7 +219,7 @@ export default function LiveChannelContents({
<>
<div className={containerClass}>
{liveInfos && liveInfos.length > 0 ? (
<TVirtualGridList
<TScrollerLiveChannel
cbScrollTo={handleScrollTo}
dataSize={liveInfos.length}
direction={direction}
@@ -230,7 +227,6 @@ export default function LiveChannelContents({
itemWidth={version === 2 ? 470 : videoVerticalVisible ? 540 : 600}
itemHeight={version === 2 ? 155 : 236}
spacing={version === 2 ? 30 : 12}
noScrollByWheel={false}
/>
) : (
<ListEmptyContents tabIndex={tabIndex} />

View File

@@ -0,0 +1,250 @@
import React, { useCallback, useEffect, useRef, useMemo } from 'react';
import classNames from 'classnames';
import { scaleH, scaleW } from '../../../../utils/helperMethods';
import css from './TScrollerLiveChannel.module.less';
/**
* TScrollerLiveChannel - Live Channel용 간단한 스크롤 컴포넌트
*
* TVirtualGridList의 가상화 대신 모든 아이템을 DOM에 렌더링
* 20개 미만의 아이템에 최적화되어 있음
*
* @param {number} dataSize - 아이템 개수
* @param {string} direction - 'horizontal' 또는 'vertical'
* @param {function} renderItem - 아이템 렌더링 함수 ({ index })
* @param {number} itemWidth - 아이템 너비
* @param {number} itemHeight - 아이템 높이
* @param {number} spacing - 아이템 간 간격
* @param {function} cbScrollTo - 스크롤 함수를 받을 콜백
* @param {string} className - 추가 CSS 클래스
* @param {string} spotlightId - Spotlight 포커스 ID prefix
*/
export default function TScrollerLiveChannel({
dataSize,
direction = 'horizontal',
renderItem,
itemWidth,
itemHeight,
spacing,
cbScrollTo,
className,
spotlightId,
}) {
const scrollContainerRef = useRef(null);
const itemsRef = useRef([]);
// 스크롤 컨테이너 크기 계산
const containerStyle = useMemo(() => {
if (direction === 'horizontal') {
return {
display: 'flex',
overflowX: 'auto',
overflowY: 'hidden',
width: '100%',
height: scaleH(itemHeight),
alignItems: 'center',
};
} else {
return {
display: 'flex',
flexDirection: 'column',
overflowY: 'auto',
overflowX: 'hidden',
width: '100%',
};
}
}, [direction, itemHeight]);
// 아이템 래퍼 스타일 계산
const itemsWrapperStyle = useMemo(() => {
if (direction === 'horizontal') {
return {
display: 'flex',
flexDirection: 'row',
gap: scaleW(spacing),
padding: `0 ${scaleW(spacing)}px`,
alignItems: 'center',
};
} else {
return {
display: 'flex',
flexDirection: 'column',
gap: scaleH(spacing),
padding: `${scaleH(spacing)}px 0`,
};
}
}, [direction, spacing]);
// 스크롤 함수 생성
const scrollToIndex = useCallback(
(index, options = {}) => {
const container = scrollContainerRef.current;
if (!container || !itemsRef.current[index]) return;
const item = itemsRef.current[index];
const { animate = true } = options;
if (direction === 'horizontal') {
// 수평 스크롤: 현재 아이템 + 다음 아이템까지 보이도록
const itemLeft = item.offsetLeft;
const itemWidth = item.offsetWidth;
const containerWidth = container.clientWidth;
// 다음 아이템도 일부 보일 수 있도록 스크롤
// 현재 아이템 + 다음 아이템의 일부가 보이는 위치로 스크롤
const nextItem = itemsRef.current[index + 1];
let scrollLeft = itemLeft - scaleW(spacing);
if (nextItem) {
// 다음 아이템의 왼쪽 끝이 컨테이너의 오른쪽 끝과 같은 위치가 되도록
const nextItemLeft = nextItem.offsetLeft;
const nextItemWidth = nextItem.offsetWidth;
const targetScrollLeft = nextItemLeft + nextItemWidth - containerWidth + scaleW(spacing);
// 두 가지 중에서 현재 아이템이 더 잘 보이는 쪽 선택
scrollLeft = Math.min(scrollLeft, targetScrollLeft);
}
// 음수 스크롤 방지
scrollLeft = Math.max(0, scrollLeft);
if (animate) {
container.scrollTo({
left: scrollLeft,
behavior: 'smooth',
});
} else {
container.scrollLeft = scrollLeft;
}
} else {
// 수직 스크롤: 현재 아이템 + 다음 아이템까지 보이도록
const itemTop = item.offsetTop;
const itemHeight = item.offsetHeight;
const containerHeight = container.clientHeight;
// 다음 아이템도 일부 보일 수 있도록 스크롤
const nextItem = itemsRef.current[index + 1];
let scrollTop = itemTop - scaleH(spacing);
if (nextItem) {
// 다음 아이템의 위쪽 끝이 컨테이너의 아래쪽 끝과 같은 위치가 되도록
const nextItemTop = nextItem.offsetTop;
const nextItemHeight = nextItem.offsetHeight;
const targetScrollTop = nextItemTop + nextItemHeight - containerHeight + scaleH(spacing);
// 두 가지 중에서 현재 아이템이 더 잘 보이는 쪽 선택
scrollTop = Math.min(scrollTop, targetScrollTop);
}
// 음수 스크롤 방지
scrollTop = Math.max(0, scrollTop);
if (animate) {
container.scrollTo({
top: scrollTop,
behavior: 'smooth',
});
} else {
container.scrollTop = scrollTop;
}
}
},
[direction, spacing]
);
// TVirtualGridList와 호환되는 콜백 인터페이스 제공
useEffect(() => {
if (cbScrollTo) {
cbScrollTo((options) => {
const { index, animate = true, focus = true } = options;
if (typeof index === 'number' && index >= 0 && index < dataSize) {
scrollToIndex(index, { animate });
}
});
}
}, [cbScrollTo, scrollToIndex, dataSize]);
// 아이템 ref 할당 함수
const setItemRef = useCallback((el, index) => {
if (el) {
itemsRef.current[index] = el;
} else {
delete itemsRef.current[index];
}
}, []);
// 포커스된 아이템을 화면에 완전히 보이도록 스크롤
const handleItemFocus = useCallback(
(index) => {
const container = scrollContainerRef.current;
const item = itemsRef.current[index];
if (!container || !item) return;
if (direction === 'horizontal') {
const itemLeft = item.offsetLeft;
const itemWidth = item.offsetWidth;
const containerWidth = container.clientWidth;
const containerScrollLeft = container.scrollLeft;
// 아이템이 완전히 보이는지 확인
const itemRight = itemLeft + itemWidth;
const containerRight = containerScrollLeft + containerWidth;
// 아이템이 왼쪽으로 밖에 나가 있으면 왼쪽 끝에 맞춤
if (itemLeft < containerScrollLeft) {
container.scrollLeft = itemLeft - scaleW(spacing);
}
// 아이템이 오른쪽으로 밖에 나가 있으면 오른쪽 끝에 맞춤
else if (itemRight > containerRight) {
container.scrollLeft = itemRight - containerWidth + scaleW(spacing);
}
} else {
const itemTop = item.offsetTop;
const itemHeight = item.offsetHeight;
const containerHeight = container.clientHeight;
const containerScrollTop = container.scrollTop;
// 아이템이 완전히 보이는지 확인
const itemBottom = itemTop + itemHeight;
const containerBottom = containerScrollTop + containerHeight;
// 아이템이 위로 밖에 나가 있으면 위쪽 끝에 맞춤
if (itemTop < containerScrollTop) {
container.scrollTop = itemTop - scaleH(spacing);
}
// 아이템이 아래로 밖에 나가 있으면 아래쪽 끝에 맞춤
else if (itemBottom > containerBottom) {
container.scrollTop = itemBottom - containerHeight + scaleH(spacing);
}
}
},
[direction, spacing]
);
return (
<div
className={classNames(css.tScrollerLiveChannelContainer, className)}
style={containerStyle}
ref={scrollContainerRef}
>
<div className={css.itemsWrapper} style={itemsWrapperStyle}>
{Array.from({ length: dataSize }).map((_, index) => (
<div
key={`item-${index}`}
ref={(el) => setItemRef(el, index)}
className={css.item}
style={{
width: direction === 'horizontal' ? scaleW(itemWidth) : 'auto',
height: direction === 'horizontal' ? 'auto' : scaleH(itemHeight),
flexShrink: 0,
}}
onFocus={() => handleItemFocus(index)}
>
{renderItem({ index })}
</div>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,41 @@
.tScrollerLiveChannelContainer {
position: relative;
width: 100%;
height: 100%;
// 스크롤바 스타일
&::-webkit-scrollbar {
width: 8px;
height: 8px;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.3);
border-radius: 4px;
&:hover {
background: rgba(255, 255, 255, 0.5);
}
}
}
.itemsWrapper {
width: 100%;
height: 100%;
}
.item {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
// 포커스 상태 처리
&:focus-within {
outline: none;
}
}

View File

@@ -5,9 +5,13 @@ import { compose } from 'ramda/src/compose';
import Spotlight from '@enact/spotlight';
import Spottable from '@enact/spotlight/Spottable';
import { Marquee, MarqueeController } from '@enact/ui/Marquee';
import {
Marquee,
MarqueeController,
} from '@enact/ui/Marquee';
import icon_arrow_dwon from '../../../../../assets/images/player/icon_tabcontainer_arrow_down.png';
import icon_arrow_dwon
from '../../../../../assets/images/player/icon_tabcontainer_arrow_down.png';
import CustomImage from '../../../../components/CustomImage/CustomImage';
import { SpotlightIds } from '../../../../utils/SpotlightIds';
import css from './LiveChannelNext.module.less';
@@ -18,7 +22,7 @@ export default function LiveChannelNext({
channelLogo,
channelName = 'ShopLC',
programName = 'Sandal Black...',
backgroundColor = 'linear-gradient(180deg, #284998 0%, #06B0EE 100%)',
backgroundColor = 'transparent',
onClick,
onFocus,
spotlightId = 'live-channel-next-button',
@@ -54,7 +58,6 @@ export default function LiveChannelNext({
<div className={css.logoWrapper}>
<div
className={css.logoBackground}
style={{ background: backgroundColor }}
>
{channelLogo ? (
<CustomImage

View File

@@ -41,6 +41,7 @@
height: 72px;
position: relative;
flex-shrink: 0;
background-color: transparent;
}
.logoBackground {
@@ -51,6 +52,7 @@
display: flex;
justify-content: center;
align-items: center;
background-color: transparent;
}
.logoImage {
@@ -60,6 +62,7 @@
&.qvcLogoImg {
width: 70%;
height: 70%;
background-color: transparent;
}
}