16 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
07a042cca6 [251217] fix: PlayerPanel 배너동영상 위치 검증추가
🕐 커밋 시간: 2025. 12. 17. 12:03:44

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

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

🔧 주요 변경 내용:
  • 소규모 기능 개선
2025-12-17 12:03:44 +09:00
20 changed files with 1370 additions and 330 deletions

View File

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

View File

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

View File

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

View File

@@ -855,9 +855,11 @@ const VideoPlayerBase = class extends React.Component {
this.state.mediaSliderVisible === nextState.mediaSliderVisible && this.state.mediaSliderVisible === nextState.mediaSliderVisible &&
this.state.loading === nextState.loading && this.state.loading === nextState.loading &&
this.props.loading === nextProps.loading && this.props.loading === nextProps.loading &&
(this.state.currentTime !== nextState.currentTime || this.state.currentTime === nextState.currentTime &&
this.state.proportionPlayed !== nextState.proportionPlayed || this.state.proportionPlayed === nextState.proportionPlayed &&
this.state.sliderTooltipTime !== nextState.sliderTooltipTime) this.state.sliderTooltipTime === nextState.sliderTooltipTime &&
this.state.mediaControlsVisible === nextState.mediaControlsVisible &&
this.state.bottomControlsRendered === nextState.bottomControlsRendered
) { ) {
return false; return false;
} }
@@ -1423,14 +1425,11 @@ const VideoPlayerBase = class extends React.Component {
sourceUnavailable: true, sourceUnavailable: true,
proportionPlayed: 0, proportionPlayed: 0,
proportionLoaded: 0, proportionLoaded: 0,
bottomControlsRendered: true,
}); });
if (!this.props.noAutoShowMediaControls) { if (!this.props.noAutoShowMediaControls) {
if (!this.state.bottomControlsRendered) { this.showControls();
this.renderBottomControl.idle();
} else {
this.showControls();
}
} }
}; };
@@ -1636,103 +1635,6 @@ const VideoPlayerBase = class extends React.Component {
updatedState.thumbnailUrl = null; updatedState.thumbnailUrl = null;
} }
this.setState(updatedState); 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(() => { 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`, * Returns an object with the current state of the media including `currentTime`, `duration`,
* `paused`, `playbackRate`, `proportionLoaded`, and `proportionPlayed`. * `paused`, `playbackRate`, `proportionLoaded`, and `proportionPlayed`.
* Redux 상태와 내부 상태를 우선적으로 사용하여 일관성 보장
* *
* @function * @function
* @memberof sandstone/VideoPlayer.VideoPlayerBase.prototype * @memberof sandstone/VideoPlayer.VideoPlayerBase.prototype
@@ -1752,19 +1653,13 @@ const VideoPlayerBase = class extends React.Component {
* @public * @public
*/ */
getMediaState = () => { getMediaState = () => {
// Redux 상태를 우선적으로 사용하여 일관성 보장
// Redux 상태가 없으면 내부 상태 사용 (fallback)
const reduxState = this.props.videoPlayState;
return { return {
currentTime: reduxState?.currentTime ?? this.state.currentTime, currentTime: this.state.currentTime,
duration: reduxState?.duration ?? this.state.duration, duration: this.state.duration,
paused: reduxState?.isPaused ?? this.state.paused, paused: this.state.paused,
playbackRate: reduxState?.playbackRate ?? this.video?.playbackRate ?? this.state.playbackRate, playbackRate: this.video?.playbackRate,
proportionLoaded: this.state.proportionLoaded, proportionLoaded: this.state.proportionLoaded,
proportionPlayed: this.state.proportionPlayed, proportionPlayed: this.state.proportionPlayed,
// Redux 상태 정보도 포함
isPlaying: reduxState?.isPlaying ?? !this.state.paused,
}; };
}; };
@@ -1793,16 +1688,7 @@ const VideoPlayerBase = class extends React.Component {
* @public * @public
*/ */
play = () => { 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) { if (this.state.sourceUnavailable) {
dwarn('⚠️ [PlayerPanel][VideoPlayer] play() aborted - source unavailable');
return; return;
} }
@@ -1814,19 +1700,6 @@ const VideoPlayerBase = class extends React.Component {
this.send('play'); this.send('play');
this.announce($L('Play')); this.announce($L('Play'));
this.startDelayedMiniFeedbackHide(5000); 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 * @public
*/ */
pause = () => { 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) { if (this.state.sourceUnavailable) {
dwarn('⚠️ [VideoPlayer] pause() aborted - source unavailable');
return; return;
} }
@@ -1858,22 +1722,6 @@ const VideoPlayerBase = class extends React.Component {
this.send('pause'); this.send('pause');
this.announce($L('Pause')); this.announce($L('Pause'));
this.stopDelayedMiniFeedbackHide(); 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 * @public
*/ */
seek = (timeIndex) => { 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.video) {
if ( if (
!this.props.seekDisabled && !this.props.seekDisabled &&
@@ -1904,34 +1743,9 @@ const VideoPlayerBase = class extends React.Component {
const actualSeekTime = const actualSeekTime =
timeIndex >= this.video.duration ? this.video.duration - 1 : timeIndex; timeIndex >= this.video.duration ? this.video.duration - 1 : timeIndex;
this.video.currentTime = actualSeekTime; 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 { } else {
derror('❌ [VideoPlayer] seek failed - disabled or source unavailable');
forward('onSeekFailed', {}, this.props); forward('onSeekFailed', {}, this.props);
} }
} else {
derror('❌ [VideoPlayer] seek failed - no video element');
} }
}; };

View File

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

View File

@@ -1,19 +1,19 @@
// VideoPlayer.module.less // VideoPlayer.module.less
// //
@import "~@enact/sandstone/styles/variables.less"; @import "~@enact/sandstone/styles/variables.less";
@import "~@enact/sandstone/styles/mixins.less"; @import "~@enact/sandstone/styles/mixins.less";
@import "~@enact/sandstone/styles/skin.less"; @import "~@enact/sandstone/styles/skin.less";
@import "../../style/utils.module.less"; @import "../../style/utils.module.less";
@import "../../style/CommonStyle.module.less"; @import "../../style/CommonStyle.module.less";
.fullscreen .videoPlayer, .fullscreen .videoPlayer,
.videoPlayer { .videoPlayer {
// Set by counting the IconButtons inside the side components. // Set by counting the IconButtons inside the side components.
--liftDistance: 0px; --liftDistance: 0px;
overflow: hidden; overflow: hidden;
padding: 2px; padding: 2px;
margin: 0; margin: 0;
box-sizing: border-box; box-sizing: border-box;
:focus { :focus {
outline: none !important; outline: none !important;
@@ -21,51 +21,51 @@
box-shadow: none !important; box-shadow: none !important;
} }
&.fullscreen { &.fullscreen {
width: 100%; width: 100%;
height: 100%; height: 100%;
} }
.video {
height: 100%;
width: 100%;
background: #000;
max-width: none;
max-height: none;
}
.media { .video {
height: var(--media-height, calc(100% - 4px)); height: 100%;
width: var(--media-width, calc(100% - 4px)); width: 100%;
background: #000; background: #000;
max-width: none;
&.mediaBackground { max-height: none;
&:after { }
width: 560px;
height: 200px; .media {
position: absolute; height: var(--media-height, calc(100% - 4px));
left: 0; width: var(--media-width, calc(100% - 4px));
bottom: 0; background: #000;
content: "";
background: linear-gradient( &.mediaBackground {
to top, &:after {
rgba(255, 255, 255, 1), width: 560px;
transparent height: 200px;
); position: absolute;
opacity: 0.2; left: 0;
} bottom: 0;
} content: "";
} background: linear-gradient(
to top,
&.fullscreen { rgba(255, 255, 255, 1),
--media-width: 100vw; transparent
--media-height: 100vh; );
} opacity: 0.2;
}
.fullscreen .videoPlayer .media { }
--media-width: 100vw; }
--media-height: 100vh;
} &.fullscreen {
--media-width: 100vw;
--media-height: 100vh;
}
.fullscreen .videoPlayer .media {
--media-width: 100vw;
--media-height: 100vh;
}
.preloadVideo { .preloadVideo {
display: none; display: none;
@@ -613,11 +613,11 @@
} }
} }
.overlay { .overlay {
.position(@position: absolute, @top: 0, @right: 0, @bottom: 0, @left: 0); .position(@position: absolute, @top: 0, @right: 0, @bottom: 0, @left: 0);
pointer-events: auto; pointer-events: auto;
z-index: 10; z-index: 10;
} }
@keyframes spin { @keyframes spin {
0% { 0% {
transform: rotate(0.25turn); transform: rotate(0.25turn);
@@ -741,11 +741,11 @@
} }
} }
.controlsHandleAbove { .controlsHandleAbove {
pointer-events: none; pointer-events: none;
z-index: -1; z-index: -1;
.position(@position: absolute, @top: 0, @right: 0, @bottom: auto, @left: 0); .position(@position: absolute, @top: 0, @right: 0, @bottom: auto, @left: 0);
} }
// Skin colors // Skin colors
.applySkins({ .applySkins({

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -617,6 +617,15 @@ export default function RandomUnit({
// 비디오 클릭 // 비디오 클릭
const videoClick = useCallback(() => { 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 lastFocusedTargetId = getContainerId(Spotlight.getCurrent());
const currentSpot = Spotlight.getCurrent(); const currentSpot = Spotlight.getCurrent();
if (lastFocusedTargetId) { if (lastFocusedTargetId) {
@@ -674,6 +683,7 @@ export default function RandomUnit({
sendBannerLog, sendBannerLog,
onBlur, onBlur,
playerPanelInfo?.modal, playerPanelInfo?.modal,
currentVideoBannerId,
dispatch, dispatch,
handleStartVideo, 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) { import Spotlight from '@enact/spotlight';
return <PlayerOverlayContents {...props} forceShowMediaOverlay />; 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

@@ -91,6 +91,51 @@ const findSelector = (selector, maxAttempts = 5, currentAttempts = 0) => {
} }
}; };
// 배너 위치 수집 함수 (top, left만 저장)
const collectBannerPositions = () => {
const positions = [];
// banner0, banner1 등의 배너 위치 수집
for (let i = 0; i < 10; i++) {
const bannerId = `banner${i}`;
const node = document.querySelector(`[data-spotlight-id="${bannerId}"]`);
if (node) {
const { top, left } = node.getBoundingClientRect();
positions.push({
bannerId,
position: { top: Math.round(top), left: Math.round(left) }
});
dlog(`[PlayerPanel] 배너 위치 수집: ${bannerId}`, { top: Math.round(top), left: Math.round(left) });
}
}
return positions;
};
// 위치 검증 함수 (오차 범위: 1px)
const isPositionMatching = (bannerPositions, bannerId, currentPosition) => {
const validPosition = bannerPositions.find(p => p.bannerId === bannerId);
if (!validPosition) {
dlog(`[PlayerPanel] 배너 위치 검증 실패: ${bannerId} 배너를 찾을 수 없음`);
return false;
}
const tolerance = 1; // 1px 오차 범위
const isMatching =
Math.abs(currentPosition.top - validPosition.position.top) <= tolerance &&
Math.abs(currentPosition.left - validPosition.position.left) <= tolerance;
dlog(`[PlayerPanel] 배너 위치 검증: ${bannerId}`, {
expected: validPosition.position,
current: currentPosition,
matching: isMatching
});
return isMatching;
};
const getLogTpNo = (type, nowMenu) => { const getLogTpNo = (type, nowMenu) => {
if (type === 'LIVE') { if (type === 'LIVE') {
switch (nowMenu) { switch (nowMenu) {
@@ -219,6 +264,7 @@ const PlayerPanel = ({ isTabActivated, panelInfo, isOnTop, spotlightId, ...props
const [tabIndexV2, setTabIndexV2] = USE_STATE('tabIndexV2', 1); // 0: ShopNow, 1: LiveChannel, 2: ShopNowButton const [tabIndexV2, setTabIndexV2] = USE_STATE('tabIndexV2', 1); // 0: ShopNow, 1: LiveChannel, 2: ShopNowButton
const [tabContainerVersion, setTabContainerVersion] = USE_STATE('tabContainerVersion', 2); // 1: TabContainer (우측), 2: TabContainerV2 (하단) const [tabContainerVersion, setTabContainerVersion] = USE_STATE('tabContainerVersion', 2); // 1: TabContainer (우측), 2: TabContainerV2 (하단)
const [isModalClosed, setIsModalClosed] = USE_STATE('isModalClosed', true); // 모달이 false 상태인지 나타내는 플래그 const [isModalClosed, setIsModalClosed] = USE_STATE('isModalClosed', true); // 모달이 false 상태인지 나타내는 플래그
const [validBannerPositions, setValidBannerPositions] = USE_STATE('validBannerPositions', []); // 유효한 배너 위치 (top, left)
const panels = USE_SELECTOR('panels', (state) => state.panels.panels); const panels = USE_SELECTOR('panels', (state) => state.panels.panels);
const chatData = USE_SELECTOR('chatData', (state) => state.play.chatData); const chatData = USE_SELECTOR('chatData', (state) => state.play.chatData);
@@ -1202,12 +1248,12 @@ const PlayerPanel = ({ isTabActivated, panelInfo, isOnTop, spotlightId, ...props
return () => { return () => {
// 패널이 2개 존재할때만 popPanel 진행 // 패널이 2개 존재할때만 popPanel 진행
// 현재 스택의 top이 PlayerPanel일 때만 pop 수행 (다른 패널이 올라온 상태에서 오작동 방지) // 현재 스택의 top이 PlayerPanel일 때만 pop 수행 (다른 패널이 올라온 상태에서 오작동 방지)
console.log('[PP-TRACE] cleanup start', { // console.log('[PP-TRACE] cleanup start', {
modal: panelInfo.modal, // modal: panelInfo.modal,
isOnTop, // isOnTop,
topPanel: panels[panels.length - 1]?.name, // topPanel: panels[panels.length - 1]?.name,
stack: panels.map((p) => p.name), // stack: panels.map((p) => p.name),
}); // });
// 🔽 [251221] PlayerPanel unmount 시 DeepLink 플래그 리셋 // 🔽 [251221] PlayerPanel unmount 시 DeepLink 플래그 리셋
dispatch( dispatch(
@@ -1226,7 +1272,7 @@ const PlayerPanel = ({ isTabActivated, panelInfo, isOnTop, spotlightId, ...props
topPanelName === panel_names.PLAYER_PANEL && topPanelName === panel_names.PLAYER_PANEL &&
panels.length === 1 // 다른 패널 존재 시 pop 금지 (DetailPanel 제거 방지) 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()); dispatch(PanelActions.popPanel());
} else { } else {
Spotlight.focus('tbody'); Spotlight.focus('tbody');
@@ -1799,6 +1845,7 @@ const PlayerPanel = ({ isTabActivated, panelInfo, isOnTop, spotlightId, ...props
if (watchIntervalLive.current) clearInterval(watchIntervalLive.current); if (watchIntervalLive.current) clearInterval(watchIntervalLive.current);
if (watchIntervalVod.current) clearInterval(watchIntervalVod.current); if (watchIntervalVod.current) clearInterval(watchIntervalVod.current);
if (watchIntervalMedia.current) clearInterval(watchIntervalMedia.current); if (watchIntervalMedia.current) clearInterval(watchIntervalMedia.current);
if (activityCheckIntervalRef.current) clearInterval(activityCheckIntervalRef.current);
}; };
}, []); }, []);
@@ -1937,6 +1984,30 @@ const PlayerPanel = ({ isTabActivated, panelInfo, isOnTop, spotlightId, ...props
panelInfo: { modalStyle: modalStyle, modalScale: scale }, panelInfo: { modalStyle: modalStyle, modalScale: scale },
}) })
); );
// 🔽 배너 위치 수집 (초기 로드 시에만 실행)
if (validBannerPositions.length === 0) {
const positions = collectBannerPositions();
if (positions.length > 0) {
setValidBannerPositions(positions);
dlog('[PlayerPanel] ✅ 배너 위치 초기 수집 완료:', positions);
}
}
// 🔽 배너 위치 검증 (위치가 맞지 않으면 비디오 재생 중단)
if (validBannerPositions.length > 0) {
const currentPosition = { top: Math.round(top), left: Math.round(left) };
const isValidPosition = isPositionMatching(validBannerPositions, panelInfo.modalContainerId, currentPosition);
if (!isValidPosition) {
dlog('[PlayerPanel] ⚠️ 배너 위치 검증 실패 - 비디오 재생 중단', {
bannerId: panelInfo.modalContainerId,
currentPosition,
validBannerPositions
});
return; // 비디오 재생 중단
}
}
} else { } else {
dlog('[PlayerPanel] Condition 1: Node not found, using saved modalStyle'); dlog('[PlayerPanel] Condition 1: Node not found, using saved modalStyle');
setModalStyle(panelInfo.modalStyle); setModalStyle(panelInfo.modalStyle);
@@ -2275,7 +2346,7 @@ const PlayerPanel = ({ isTabActivated, panelInfo, isOnTop, spotlightId, ...props
setPrevChannelIndex(selectedIndex); setPrevChannelIndex(selectedIndex);
} }
setSideContentsVisible(true); setSideContentsVisible(true);
}, [dispatch, playListInfo, selectedIndex, sideContentsVisible, initialEnter]); }, [dispatch, playListInfo, selectedIndex, sideContentsVisible, initialEnter, tabContainerVersion, tabIndexV2]);
const handleIndicatorUpClick = useCallback(() => { const handleIndicatorUpClick = useCallback(() => {
if (!initialEnter) { if (!initialEnter) {
@@ -2323,7 +2394,7 @@ const PlayerPanel = ({ isTabActivated, panelInfo, isOnTop, spotlightId, ...props
setPrevChannelIndex(selectedIndex); setPrevChannelIndex(selectedIndex);
} }
setSideContentsVisible(true); setSideContentsVisible(true);
}, [dispatch, playListInfo, selectedIndex, sideContentsVisible, initialEnter]); }, [dispatch, playListInfo, selectedIndex, sideContentsVisible, initialEnter, tabContainerVersion, tabIndexV2]);
useEffect(() => { useEffect(() => {
if (panelInfo.shptmBanrTpNm === 'VOD' && panelInfo.patnrId && panelInfo.showId) { if (panelInfo.shptmBanrTpNm === 'VOD' && panelInfo.patnrId && panelInfo.showId) {
@@ -2465,14 +2536,105 @@ const PlayerPanel = ({ isTabActivated, panelInfo, isOnTop, spotlightId, ...props
); );
const onKeyDown = (ev) => { 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(); handleIndicatorDownClick();
ev.stopPropagation(); ev.stopPropagation();
ev.preventDefault(); ev.preventDefault();
} else if (ev.keyCode === 33) { dlog('[PlayerPanel] 📺 PageDown (버튼 또는 다른 경우) -> 다음 비디오');
} else if (ev.keyCode === 33) { // PageUp
handleIndicatorUpClick(); handleIndicatorUpClick();
ev.stopPropagation(); ev.stopPropagation();
ev.preventDefault(); ev.preventDefault();
dlog('[PlayerPanel] 📺 PageUp (버튼 또는 다른 경우) -> 이전 비디오');
} }
}; };
@@ -2483,6 +2645,11 @@ const PlayerPanel = ({ isTabActivated, panelInfo, isOnTop, spotlightId, ...props
const timerIdTabAutoAdvance = useRef(null); const timerIdTabAutoAdvance = useRef(null);
const prevTabIndexV2 = 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(() => { const showSideContents = useMemo(() => {
return ( return (
sideContentsVisible && sideContentsVisible &&
@@ -2594,17 +2761,62 @@ const PlayerPanel = ({ isTabActivated, panelInfo, isOnTop, spotlightId, ...props
timerIdTabAutoAdvance.current = null; 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( const resetTimerTabAutoAdvance = useCallback(
(timeout) => { (timeout) => {
if (timerIdTabAutoAdvance.current) { if (timerIdTabAutoAdvance.current) {
clearTimerTabAutoAdvance(); clearTimerTabAutoAdvance();
} }
timerIdTabAutoAdvance.current = setTimeout(() => { // Activity check interval 설정 (매 100ms마다 체크)
setTabIndexV2(2); if (activityCheckIntervalRef.current) {
}, timeout); 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);
}
} else {
// 활동이 감지되면 경과 시간 리셋
dlog('[PlayerPanel] 🔄 Activity detected - resetting elapsed time', {
previousElapsed: elapsedTime,
});
elapsedTime = 0;
}
}, 100);
}, },
[clearTimerTabAutoAdvance] [clearTimerTabAutoAdvance, isInactive]
); );
// Redux로 오버레이 숨김 // Redux로 오버레이 숨김
@@ -2629,6 +2841,10 @@ const PlayerPanel = ({ isTabActivated, panelInfo, isOnTop, spotlightId, ...props
if (timerIdTabAutoAdvance.current) { if (timerIdTabAutoAdvance.current) {
clearTimerTabAutoAdvance(); clearTimerTabAutoAdvance();
} }
if (activityCheckIntervalRef.current) {
clearInterval(activityCheckIntervalRef.current);
activityCheckIntervalRef.current = null;
}
dispatch(resetPlayerOverlays()); dispatch(resetPlayerOverlays());
} }
@@ -2843,6 +3059,53 @@ const PlayerPanel = ({ isTabActivated, panelInfo, isOnTop, spotlightId, ...props
panelInfo?.modal, 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 자동 다음 단계로 이동 // TabIndex 1 자동 다음 단계로 이동
useEffect(() => { useEffect(() => {
// tabIndex === 1일 때만 실행 // tabIndex === 1일 때만 실행
@@ -2869,6 +3132,31 @@ const PlayerPanel = ({ isTabActivated, panelInfo, isOnTop, spotlightId, ...props
clearTimerTabAutoAdvance, 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(() => { useLayoutEffect(() => {
const videoContainer = document.querySelector(`.${css.videoContainer}`); const videoContainer = document.querySelector(`.${css.videoContainer}`);

View File

@@ -5,9 +5,8 @@ import { useDispatch } from 'react-redux';
import Spotlight from '@enact/spotlight'; import Spotlight from '@enact/spotlight';
import { sendLogTotalRecommend } from '../../../../actions/logActions'; import { sendLogTotalRecommend } from '../../../../actions/logActions';
// <<<<<<< HEAD
import { updatePanel } from '../../../../actions/panelActions'; import { updatePanel } from '../../../../actions/panelActions';
import TVirtualGridList from '../../../../components/TVirtualGridList/TVirtualGridList'; import TScrollerLiveChannel from './TScrollerLiveChannel';
import { import {
LOG_CONTEXT_NAME, LOG_CONTEXT_NAME,
LOG_MENU, LOG_MENU,
@@ -20,26 +19,11 @@ import ListEmptyContents from '../TabContents/ListEmptyContents/ListEmptyContent
import css from './LiveChannelContents.module.less'; import css from './LiveChannelContents.module.less';
import cssV2 from './LiveChannelContents.v2.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({ export default function LiveChannelContents({
liveInfos, liveInfos,
currentTime, currentTime,
setSelectedIndex, setSelectedIndex,
selectedIndex,
videoVerticalVisible, videoVerticalVisible,
currentVideoShowId, currentVideoShowId,
tabIndex, tabIndex,
@@ -83,6 +67,19 @@ export default function LiveChannelContents({
} }
}, [isFilteredByPatnr19]); }, [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( const renderItem = useCallback(
({ index, ...rest }) => { ({ index, ...rest }) => {
const { const {
@@ -222,7 +219,7 @@ export default function LiveChannelContents({
<> <>
<div className={containerClass}> <div className={containerClass}>
{liveInfos && liveInfos.length > 0 ? ( {liveInfos && liveInfos.length > 0 ? (
<TVirtualGridList <TScrollerLiveChannel
cbScrollTo={handleScrollTo} cbScrollTo={handleScrollTo}
dataSize={liveInfos.length} dataSize={liveInfos.length}
direction={direction} direction={direction}
@@ -230,7 +227,6 @@ export default function LiveChannelContents({
itemWidth={version === 2 ? 470 : videoVerticalVisible ? 540 : 600} itemWidth={version === 2 ? 470 : videoVerticalVisible ? 540 : 600}
itemHeight={version === 2 ? 155 : 236} itemHeight={version === 2 ? 155 : 236}
spacing={version === 2 ? 30 : 12} spacing={version === 2 ? 30 : 12}
noScrollByWheel={false}
/> />
) : ( ) : (
<ListEmptyContents tabIndex={tabIndex} /> <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 Spotlight from '@enact/spotlight';
import Spottable from '@enact/spotlight/Spottable'; 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 CustomImage from '../../../../components/CustomImage/CustomImage';
import { SpotlightIds } from '../../../../utils/SpotlightIds'; import { SpotlightIds } from '../../../../utils/SpotlightIds';
import css from './LiveChannelNext.module.less'; import css from './LiveChannelNext.module.less';
@@ -18,7 +22,7 @@ export default function LiveChannelNext({
channelLogo, channelLogo,
channelName = 'ShopLC', channelName = 'ShopLC',
programName = 'Sandal Black...', programName = 'Sandal Black...',
backgroundColor = 'linear-gradient(180deg, #284998 0%, #06B0EE 100%)', backgroundColor = 'transparent',
onClick, onClick,
onFocus, onFocus,
spotlightId = 'live-channel-next-button', spotlightId = 'live-channel-next-button',
@@ -53,8 +57,7 @@ export default function LiveChannelNext({
> >
<div className={css.logoWrapper}> <div className={css.logoWrapper}>
<div <div
className={css.logoBackground} className={css.logoBackground}
style={{ background: backgroundColor }}
> >
{channelLogo ? ( {channelLogo ? (
<CustomImage <CustomImage

View File

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