diff --git a/com.twin.app.shoptime/src/views/PlayerPanel/PlayerTabContents/TabContents/LiveChannelContents.jsx b/com.twin.app.shoptime/src/views/PlayerPanel/PlayerTabContents/TabContents/LiveChannelContents.jsx index 5168f882..f3234e0f 100644 --- a/com.twin.app.shoptime/src/views/PlayerPanel/PlayerTabContents/TabContents/LiveChannelContents.jsx +++ b/com.twin.app.shoptime/src/views/PlayerPanel/PlayerTabContents/TabContents/LiveChannelContents.jsx @@ -5,9 +5,8 @@ import { useDispatch } from 'react-redux'; import Spotlight from '@enact/spotlight'; import { sendLogTotalRecommend } from '../../../../actions/logActions'; -// <<<<<<< HEAD import { updatePanel } from '../../../../actions/panelActions'; -import TVirtualGridList from '../../../../components/TVirtualGridList/TVirtualGridList'; +import TScrollerLiveChannel from './TScrollerLiveChannel'; import { LOG_CONTEXT_NAME, LOG_MENU, @@ -20,22 +19,6 @@ import ListEmptyContents from '../TabContents/ListEmptyContents/ListEmptyContent import css from './LiveChannelContents.module.less'; import cssV2 from './LiveChannelContents.v2.module.less'; -// ======= -// import { updatePanel } from "../../../../actions/panelActions"; -// import TVirtualGridList from "../../../../components/TVirtualGridList/TVirtualGridList"; -// import { -// LOG_CONTEXT_NAME, -// LOG_MENU, -// LOG_MESSAGE_ID, -// panel_names, -// } from "../../../../utils/Config"; -// import { $L } from "../../../../utils/helperMethods"; -// import PlayerItemCard, { TYPES } from "../../PlayerItemCard/PlayerItemCard"; -// import ListEmptyContents from "../TabContents/ListEmptyContents/ListEmptyContents"; -// import css from "./LiveChannelContents.module.less"; -// import { sendLogTotalRecommend } from "../../../../actions/logActions"; -// >>>>>>> gitlab/develop - export default function LiveChannelContents({ liveInfos, currentTime, @@ -222,7 +205,7 @@ export default function LiveChannelContents({ <>
{liveInfos && liveInfos.length > 0 ? ( - ) : ( diff --git a/com.twin.app.shoptime/src/views/PlayerPanel/PlayerTabContents/TabContents/TScrollerLiveChannel.jsx b/com.twin.app.shoptime/src/views/PlayerPanel/PlayerTabContents/TabContents/TScrollerLiveChannel.jsx new file mode 100644 index 00000000..fc142a58 --- /dev/null +++ b/com.twin.app.shoptime/src/views/PlayerPanel/PlayerTabContents/TabContents/TScrollerLiveChannel.jsx @@ -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 ( +
+
+ {Array.from({ length: dataSize }).map((_, index) => ( +
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 })} +
+ ))} +
+
+ ); +} diff --git a/com.twin.app.shoptime/src/views/PlayerPanel/PlayerTabContents/TabContents/TScrollerLiveChannel.module.less b/com.twin.app.shoptime/src/views/PlayerPanel/PlayerTabContents/TabContents/TScrollerLiveChannel.module.less new file mode 100644 index 00000000..0da7f769 --- /dev/null +++ b/com.twin.app.shoptime/src/views/PlayerPanel/PlayerTabContents/TabContents/TScrollerLiveChannel.module.less @@ -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; + } +}