16 Commits

Author SHA1 Message Date
daac18afa8 [251217] fix: LiveChannelContents PageUp/Down시 첫번째로 배너위치
🕐 커밋 시간: 2025. 12. 17. 20:11:18

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

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

🔧 주요 변경 내용:
  • 소규모 기능 개선
  • 코드 정리 및 최적화
2025-12-17 20:11:19 +09:00
adfede9b44 [251217] fix: LiveChannelContents.jsx 아이템포커스 테두리 수정
🕐 커밋 시간: 2025. 12. 17. 20:02:01

📊 변경 통계:
  • 총 파일: 2개

📝 수정된 파일:
  ~ com.twin.app.shoptime/src/style/utils.module.less
  ~ com.twin.app.shoptime/src/views/PlayerPanel/PlayerItemCard/PlayerItemCard.v2.module.less

🔧 주요 변경 내용:
  • 공통 유틸리티 함수 최적화
2025-12-17 20:02:02 +09:00
1fae88878f [251217] fix: MediaPanel.v3.jsx 비디오복원 방어로직 추가
🕐 커밋 시간: 2025. 12. 17. 19:51:27

📊 변경 통계:
  • 총 파일: 2개
  • 추가: +117줄

📝 수정된 파일:
  ~ com.twin.app.shoptime/src/views/DetailPanel/ProductAllSection/ProductAllSection.jsx
  ~ com.twin.app.shoptime/src/views/MediaPanel/MediaPanel.v3.jsx

🔧 주요 변경 내용:
  • 중간 규모 기능 개선
2025-12-17 19:51:27 +09:00
4ecb03002f [251217] fix: VideoPlayer.v3.jsx times 전체화면에만 표시
🕐 커밋 시간: 2025. 12. 17. 18:19:37

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

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

🔧 주요 변경 내용:
  • UI 컴포넌트 아키텍처 개선
  • 코드 정리 및 최적화
2025-12-17 18:19:37 +09:00
27e1e3bb6a [251217] fix: VideoPlayer.v3.jsx slider , times layout
🕐 커밋 시간: 2025. 12. 17. 18:16:49

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

📝 수정된 파일:
  ~ com.twin.app.shoptime/src/components/VideoPlayer/VideoPlayer.v3.module.less

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

Performance: 코드 최적화로 성능 개선 기대
2025-12-17 18:16:49 +09:00
b9d52d452c [251217] fix: VideoPlayer.v3.jsx Spinner제거
🕐 커밋 시간: 2025. 12. 17. 18:04:43

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

📝 수정된 파일:
  ~ com.twin.app.shoptime/src/components/VideoPlayer/VideoPlayer.v3.js
  ~ com.twin.app.shoptime/src/components/VideoPlayer/VideoPlayer.v3.module.less

🔧 주요 변경 내용:
  • UI 컴포넌트 아키텍처 개선
2025-12-17 18:04:44 +09:00
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
21 changed files with 1187 additions and 393 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,15 +1281,12 @@ 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.renderBottomControl.idle();
} else {
this.showControls(); this.showControls();
} }
}
}; };
handlePlay = this.handle(forwardPlay, this.shouldShowMiniFeedback, () => this.play()); handlePlay = this.handle(forwardPlay, this.shouldShowMiniFeedback, () => this.play());

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,15 +1425,12 @@ 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.renderBottomControl.idle();
} else {
this.showControls(); this.showControls();
} }
}
}; };
handlePlay = this.handle(forwardPlay, this.shouldShowMiniFeedback, () => this.play()); handlePlay = this.handle(forwardPlay, this.shouldShowMiniFeedback, () => this.play());
@@ -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 { } else {
dwarn('⚠️ [VideoPlayer] No dispatch prop available - Redux state not updated');
}
} 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

@@ -2467,9 +2467,9 @@ const VideoPlayerBase = class extends React.Component {
</p> </p>
} }
<div className={css.loaderWrap}> {/* <div className={css.loaderWrap}>
<Loader /> <Loader />
</div> </div> */}
</> </>
) : null} ) : null}
{this.state.mediaControlsVisible && ( {this.state.mediaControlsVisible && (
@@ -2534,7 +2534,7 @@ const VideoPlayerBase = class extends React.Component {
videoVerticalVisible && css.videoVertical videoVerticalVisible && css.videoVertical
)} )}
> >
{this.state.mediaSliderVisible && type ? ( {this.state.mediaSliderVisible && type && !panelInfo.modal ? (
<Times <Times
noCurrentTime noCurrentTime
total={type === 'LIVE' ? liveTotalTime : this.state.duration} total={type === 'LIVE' ? liveTotalTime : this.state.duration}
@@ -2542,7 +2542,7 @@ const VideoPlayerBase = class extends React.Component {
type={type} type={type}
/> />
) : null} ) : null}
{this.state.mediaSliderVisible && type ? ( {this.state.mediaSliderVisible && type && !panelInfo.modal ? (
<Times <Times
noTotalTime noTotalTime
current={type === 'LIVE' ? currentLiveTimeSeconds : this.state.currentTime} current={type === 'LIVE' ? currentLiveTimeSeconds : this.state.currentTime}

View File

@@ -718,7 +718,7 @@
} }
.sliderContainer { .sliderContainer {
// display: flex; display: flex;
position: relative; position: relative;
align-items: center; align-items: center;
margin-left: 60px; margin-left: 60px;
@@ -727,6 +727,10 @@
bottom: -20px; bottom: -20px;
> *:first-child { > *:first-child {
text-align: right; text-align: right;
margin-right: 12px;
}
> *:nth-child(2) {
margin-right: 12px;
} }
.enact-locale-rtl({ .enact-locale-rtl({
@@ -787,79 +791,4 @@
}); });
} }
// ========== MediaPlayer.v2 Controls ==========
.controlsContainer {
position: absolute;
bottom: 0;
left: 0;
right: 0;
padding: 20px 40px 30px;
background: linear-gradient(to top, rgba(0, 0, 0, 0.9) 0%, rgba(0, 0, 0, 0.7) 60%, transparent 100%);
z-index: 10;
display: flex;
flex-direction: column;
gap: 16px;
}
.sliderContainer {
display: flex;
align-items: center;
gap: 12px;
width: 100%;
}
.times {
min-width: 80px;
text-align: center;
}
.controlsButtons {
display: flex;
gap: 20px;
justify-content: center;
align-items: center;
}
.playPauseBtn {
width: 60px;
height: 60px;
font-size: 24px;
background: rgba(255, 255, 255, 0.2);
border: 2px solid rgba(255, 255, 255, 0.6);
border-radius: 50%;
color: white;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
&:hover {
background: rgba(255, 255, 255, 0.3);
border-color: white;
}
&:active {
transform: scale(0.95);
}
}
.backBtn {
padding: 12px 24px;
font-size: 18px;
background: rgba(255, 255, 255, 0.2);
border: 2px solid rgba(255, 255, 255, 0.6);
border-radius: 8px;
color: white;
cursor: pointer;
transition: all 0.2s;
&:hover {
background: rgba(255, 255, 255, 0.3);
border-color: white;
}
&:active {
transform: scale(0.98);
}
}

View File

@@ -10,7 +10,6 @@
.position(@position: absolute, @top: 0, @left: 0, @right: 0, @bottom: 0); .position(@position: absolute, @top: 0, @left: 0, @right: 0, @bottom: 0);
z-index: 19; z-index: 19;
border: 4px solid @PRIMARY_COLOR_RED; border: 4px solid @PRIMARY_COLOR_RED;
box-shadow: 0 0 @boxShadow 0 rgba(0, 0, 0, 0.5);
border-radius: @borderRadius; border-radius: @borderRadius;
content: ""; content: "";
} }

View File

@@ -183,6 +183,57 @@ const ButtonStackContainer = SpotlightContainerDecorator(
const SpottableComponent = Spottable('div'); const SpottableComponent = Spottable('div');
// 🔽 [251217] ProductAllSection 스크롤 컨테이너 위치 저장/검증 함수
// PlayerPanel의 배너 위치 검증 패턴을 ProductAllSection에 적용
/**
* ProductAllSection의 스크롤 컨테이너 위치 수집 함수
* @returns {Object|null} 스크롤 컨테이너의 위치 정보 (top, left) 또는 null
*/
const collectProductScrollPosition = () => {
const scrollContainer = document.querySelector('[data-spotlight-id="main-content-scroller"]');
if (scrollContainer) {
const { top, left } = scrollContainer.getBoundingClientRect();
const position = {
top: Math.round(top),
left: Math.round(left)
};
console.log('[ProductAllSection] 스크롤 컨테이너 위치 수집:', position);
return position;
}
console.warn('[ProductAllSection] 스크롤 컨테이너를 찾을 수 없음');
return null;
};
/**
* ProductAllSection 스크롤 컨테이너 위치 검증 함수
* @param {Object} savedPosition - 저장된 초기 위치
* @param {Object} currentPosition - 현재 위치
* @returns {boolean} 위치가 일치하는지 여부
*/
const isProductScrollPositionValid = (savedPosition, currentPosition) => {
if (!savedPosition || !currentPosition) {
console.warn('[ProductAllSection] 저장된 위치 또는 현재 위치가 없음');
return false;
}
const tolerance = 1; // 1px 오차 범위
const isMatching =
Math.abs(currentPosition.top - savedPosition.top) <= tolerance &&
Math.abs(currentPosition.left - savedPosition.left) <= tolerance;
console.log('[ProductAllSection] 스크롤 위치 검증:', {
expected: savedPosition,
current: currentPosition,
matching: isMatching,
tolerance
});
return isMatching;
};
const getProductData = curry( const getProductData = curry(
(productType, themeProductInfo, themeProducts, selectedIndex, productInfo) => (productType, themeProductInfo, themeProducts, selectedIndex, productInfo) =>
pipe( pipe(
@@ -304,6 +355,9 @@ export default function ProductAllSection({
// handleScrollToImages의 timeout을 추적하기 위한 ref // handleScrollToImages의 timeout을 추적하기 위한 ref
const scrollToImagesTimeoutRef = useRef(null); const scrollToImagesTimeoutRef = useRef(null);
// 🔽 [251217] 스크롤 컨테이너 초기 위치 저장 ref (VideoPlayer modal 전환 시 위치 검증용)
const scrollPositionOnMountRef = useRef(null);
// ProductAllSection 초기 로딩 시 Skeleton 표시를 위한 상태 // ProductAllSection 초기 로딩 시 Skeleton 표시를 위한 상태
const [isInitialLoading, setIsInitialLoading] = useState(true); const [isInitialLoading, setIsInitialLoading] = useState(true);
@@ -344,6 +398,19 @@ export default function ProductAllSection({
); );
}, [selectedPatnrId, selectedPrdtId, userNumber, dispatch]); }, [selectedPatnrId, selectedPrdtId, userNumber, dispatch]);
// 🔽 [251217] ProductAllSection 마운트 시 스크롤 컨테이너 위치 저장
useEffect(() => {
// 초기 렌더링 후 스크롤 컨테이너의 위치를 저장 (1회만 실행)
// 이 위치는 나중에 VideoPlayer의 modal 전환 시 유효성 검증에 사용됨
const savedPosition = collectProductScrollPosition();
if (savedPosition) {
scrollPositionOnMountRef.current = savedPosition;
// 🔽 window 객체에도 저장하여 MediaPanel.v3.jsx에서 접근 가능하게 함
window.productScrollPositionOnMount = savedPosition;
console.log('[ProductAllSection] ✅ 초기 스크롤 위치 저장 완료:', savedPosition);
}
}, []); // 마운트 시 1회만 실행
useEffect(() => { useEffect(() => {
// 필수 값이 모두 있을 때만 호출 // 필수 값이 모두 있을 때만 호출
if (selectedPatnrId && selectedPrdtId) { if (selectedPatnrId && selectedPrdtId) {

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}

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

@@ -93,6 +93,36 @@ const findSelector = (selector, maxAttempts = 5, currentAttempts = 0) => {
} }
}; };
// 🔽 [251217] ProductAllSection 스크롤 컨테이너 위치 수집 함수
const collectProductScrollPosition = () => {
const scrollContainer = document.querySelector('[data-spotlight-id="main-content-scroller"]');
if (scrollContainer) {
const { top, left } = scrollContainer.getBoundingClientRect();
const position = {
top: Math.round(top),
left: Math.round(left)
};
return position;
}
return null;
};
// 🔽 [251217] ProductAllSection 스크롤 컨테이너 위치 검증 함수
const isProductScrollPositionValid = (savedPosition, currentPosition) => {
if (!savedPosition || !currentPosition) {
return false;
}
const tolerance = 1; // 1px 오차 범위
const isMatching =
Math.abs(currentPosition.top - savedPosition.top) <= tolerance &&
Math.abs(currentPosition.left - savedPosition.left) <= tolerance;
return isMatching;
};
const getLogTpNo = (type, nowMenu) => { const getLogTpNo = (type, nowMenu) => {
if (type === 'LIVE') { if (type === 'LIVE') {
switch (nowMenu) { switch (nowMenu) {
@@ -1107,6 +1137,26 @@ const MediaPanel = React.forwardRef(
}, [dispatch]); }, [dispatch]);
const enterFullscreen = useCallback(() => { const enterFullscreen = useCallback(() => {
// 🔽 [251217] ProductAllSection 스크롤 위치 검증
// DetailPanel에서 ProductAllSection의 초기 위치와 현재 위치를 비교하여 검증
const savedPosition = window.productScrollPositionOnMount;
const currentPosition = collectProductScrollPosition();
if (savedPosition && currentPosition) {
const isValid = isProductScrollPositionValid(savedPosition, currentPosition);
if (!isValid) {
console.warn('[MediaPanel] ⚠️ ProductAllSection 스크롤 위치 검증 실패 - 전체화면 재생 가능하지만 경고 표시', {
savedPosition,
currentPosition,
});
// 사용자 피드백: 위치가 일치하지 않음을 콘솔에 기록 (비디오는 계속 재생)
} else {
console.log('[MediaPanel] ✅ ProductAllSection 스크롤 위치 검증 성공');
}
} else {
console.warn('[MediaPanel] ⚠️ ProductAllSection 위치 정보 없음 - 검증 스킵');
}
isTransitioningToFullscreen.current = true; isTransitioningToFullscreen.current = true;
dispatch(switchMediaToFullscreen()); dispatch(switchMediaToFullscreen());
}, [dispatch]); }, [dispatch]);

View File

@@ -167,8 +167,8 @@
left: 0; left: 0;
width: 100%; width: 100%;
height: 100%; height: 100%;
border: 4px solid @PRIMARY_COLOR_RED;
border-radius: 12px; border-radius: 12px;
box-shadow: 0 0 22px @PRIMARY_COLOR_RED;
pointer-events: none; pointer-events: none;
} }
} }
@@ -298,8 +298,8 @@
left: 0; left: 0;
width: 100%; width: 100%;
height: 100%; height: 100%;
border: 4px solid @PRIMARY_COLOR_RED;
border-radius: 12px; border-radius: 12px;
box-shadow: 0 0 22px @PRIMARY_COLOR_RED;
pointer-events: none; pointer-events: none;
} }
} }

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();
} }
export default MediaOverlayContents; 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>
)}
</>
);
}
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

@@ -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, alignToStart: true });
}
}
}, [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,263 @@
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, alignToStart = false } = options;
if (direction === 'horizontal') {
// 수평 스크롤: 스크롤 가능 여부 판단
const isScrollable = container.scrollWidth > container.clientWidth;
let scrollLeft = 0;
if (alignToStart && isScrollable) {
// 첫 번째 위치로 스크롤
scrollLeft = item.offsetLeft - scaleW(spacing);
} else if (!alignToStart) {
// 기존 로직: 현재 아이템 + 다음 아이템까지 보이도록
const itemLeft = item.offsetLeft;
const itemWidth = item.offsetWidth;
const containerWidth = container.clientWidth;
const nextItem = itemsRef.current[index + 1];
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 isScrollable = container.scrollHeight > container.clientHeight;
let scrollTop = 0;
if (alignToStart && isScrollable) {
// 첫 번째 위치로 스크롤
scrollTop = item.offsetTop - scaleH(spacing);
} else if (!alignToStart) {
// 기존 로직: 현재 아이템 + 다음 아이템까지 보이도록
const itemTop = item.offsetTop;
const itemHeight = item.offsetHeight;
const containerHeight = container.clientHeight;
const nextItem = itemsRef.current[index + 1];
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, alignToStart = false } = options;
if (typeof index === 'number' && index >= 0 && index < dataSize) {
scrollToIndex(index, { animate, alignToStart });
}
});
}
}, [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;
}
}