diff --git a/src/app/tv/play/page.tsx b/src/app/tv/play/page.tsx index db1ef83..a2e16ae 100644 --- a/src/app/tv/play/page.tsx +++ b/src/app/tv/play/page.tsx @@ -71,6 +71,11 @@ const TV_DANMAKU_SETTINGS_KEY = 'tv_danmaku_settings'; const TV_VOLUME_KEY = 'tv_player_volume'; const TV_MUTED_KEY = 'tv_player_muted'; const REMOTE_KEY_DEDUPE_MS = 350; +const TV_EPISODE_GRID_COLUMNS = 4; + +const TV_EPISODE_FOCUS_GROUPS = ['sources', 'pages', 'episodes'] as const; +type TVEpisodeFocusGroup = (typeof TV_EPISODE_FOCUS_GROUPS)[number]; +type TVEpisodeFocusDirection = 'up' | 'down' | 'left' | 'right'; type TVDanmakuSettings = { fontSize: number; @@ -215,6 +220,198 @@ function moveFocusWithinScope(scope: HTMLElement, direction: 'up' | 'down') { elements[nextIndex]?.focus({ preventScroll: true }); } +function getEpisodePanelGroupElements( + panel: HTMLElement, + group: TVEpisodeFocusGroup +) { + return Array.from( + panel.querySelectorAll( + `[data-tv-episode-focus-group="${group}"]` + ) + ); +} + +function getEpisodePanelFirstElement(panel: HTMLElement) { + for (const group of TV_EPISODE_FOCUS_GROUPS) { + const first = getEpisodePanelGroupElements(panel, group)[0]; + if (first) return first; + } + return null; +} + +function focusEpisodePanelElement(element: HTMLElement | null | undefined) { + if (!element) return false; + element.focus({ preventScroll: true }); + element.scrollIntoView({ + block: 'nearest', + inline: 'nearest', + behavior: 'smooth', + }); + return true; +} + +function getClosestByHorizontalCenter( + elements: HTMLElement[], + anchor: HTMLElement +) { + if (elements.length === 0) return null; + const anchorRect = anchor.getBoundingClientRect(); + const anchorCenter = anchorRect.left + anchorRect.width / 2; + return elements.reduce((closest, item) => { + const itemRect = item.getBoundingClientRect(); + const itemCenter = itemRect.left + itemRect.width / 2; + const closestRect = closest.getBoundingClientRect(); + const closestCenter = closestRect.left + closestRect.width / 2; + return Math.abs(itemCenter - anchorCenter) < + Math.abs(closestCenter - anchorCenter) + ? item + : closest; + }, elements[0]); +} + +function getSelectedEpisodePanelButton( + panel: HTMLElement, + episodeIndex: number +) { + return panel.querySelector( + `[data-tv-episode-index="${episodeIndex}"]` + ); +} + +function focusEpisodePanelInitial( + panel: HTMLElement | null, + episodeIndex: number +) { + if (!panel) return false; + const active = document.activeElement; + if (active instanceof HTMLElement && panel.contains(active)) { + focusEpisodePanelElement(active); + return true; + } + + return focusEpisodePanelElement( + getSelectedEpisodePanelButton(panel, episodeIndex) || + getEpisodePanelFirstElement(panel) + ); +} + +function moveFocusWithinEpisodePanel( + panel: HTMLElement | null, + direction: TVEpisodeFocusDirection, + episodeIndex: number +) { + if (!panel) return; + + const active = document.activeElement; + const activeElement = + active instanceof HTMLElement && panel.contains(active) + ? active.closest('[data-tv-episode-focus-group]') + : null; + + if (!activeElement) { + focusEpisodePanelInitial(panel, episodeIndex); + return; + } + + const group = activeElement.dataset.tvEpisodeFocusGroup as + | TVEpisodeFocusGroup + | undefined; + if (!group || !TV_EPISODE_FOCUS_GROUPS.includes(group)) { + focusEpisodePanelInitial(panel, episodeIndex); + return; + } + + const elements = getEpisodePanelGroupElements(panel, group); + const index = elements.indexOf(activeElement); + if (index < 0) { + focusEpisodePanelInitial(panel, episodeIndex); + return; + } + + let target: HTMLElement | null = activeElement; + + if (direction === 'left' || direction === 'right') { + const delta = direction === 'right' ? 1 : -1; + + if (group === 'episodes') { + const rowStart = + Math.floor(index / TV_EPISODE_GRID_COLUMNS) * TV_EPISODE_GRID_COLUMNS; + const rowEnd = Math.min( + rowStart + TV_EPISODE_GRID_COLUMNS - 1, + elements.length - 1 + ); + const nextIndex = Math.max(rowStart, Math.min(rowEnd, index + delta)); + target = elements[nextIndex] || activeElement; + } else { + const nextIndex = Math.max( + 0, + Math.min(elements.length - 1, index + delta) + ); + target = elements[nextIndex] || activeElement; + } + + focusEpisodePanelElement(target); + return; + } + + if (group === 'episodes') { + const nextIndex = + direction === 'up' + ? index - TV_EPISODE_GRID_COLUMNS + : index + TV_EPISODE_GRID_COLUMNS; + + if (nextIndex >= 0 && nextIndex < elements.length) { + target = elements[nextIndex] || activeElement; + } else if (direction === 'up') { + target = + getClosestByHorizontalCenter( + getEpisodePanelGroupElements(panel, 'pages'), + activeElement + ) || + getClosestByHorizontalCenter( + getEpisodePanelGroupElements(panel, 'sources'), + activeElement + ) || + activeElement; + } + + focusEpisodePanelElement(target); + return; + } + + if (group === 'pages') { + target = + direction === 'up' + ? getClosestByHorizontalCenter( + getEpisodePanelGroupElements(panel, 'sources'), + activeElement + ) || activeElement + : getSelectedEpisodePanelButton(panel, episodeIndex) || + getClosestByHorizontalCenter( + getEpisodePanelGroupElements(panel, 'episodes'), + activeElement + ) || + activeElement; + focusEpisodePanelElement(target); + return; + } + + target = + direction === 'down' + ? getClosestByHorizontalCenter( + getEpisodePanelGroupElements(panel, 'pages'), + activeElement + ) || + getSelectedEpisodePanelButton(panel, episodeIndex) || + getClosestByHorizontalCenter( + getEpisodePanelGroupElements(panel, 'episodes'), + activeElement + ) || + activeElement + : activeElement; + focusEpisodePanelElement(target); +} + function TVPlayClient() { const router = useRouter(); const searchParams = useSearchParams(); @@ -287,6 +484,7 @@ function TVPlayClient() { } | null>(null); const [time, setTime] = useState({ current: 0, duration: 0 }); const timeRef = useRef({ current: 0, duration: 0 }); + const episodesPanelRef = useRef(null); const episodeButtonRefs = useRef>( {} ); @@ -907,6 +1105,38 @@ function TVPlayClient() { return; } + if ( + showEpisodes && + (event.key === 'ArrowLeft' || + event.key === 'ArrowRight' || + event.key === 'ArrowUp' || + event.key === 'ArrowDown') + ) { + event.preventDefault(); + event.stopImmediatePropagation(); + revealPanel(); + moveFocusWithinEpisodePanel( + episodesPanelRef.current, + event.key + .replace('Arrow', '') + .toLowerCase() as TVEpisodeFocusDirection, + episodeIndex + ); + return; + } + + if (showEpisodes && event.key === 'Tab') { + event.preventDefault(); + event.stopImmediatePropagation(); + revealPanel(); + moveFocusWithinEpisodePanel( + episodesPanelRef.current, + event.shiftKey ? 'up' : 'down', + episodeIndex + ); + return; + } + if ( !showPanel && !showEpisodes && @@ -974,10 +1204,40 @@ function TVPlayClient() { setEpisodePage(targetPage); window.scrollTo({ top: 0, left: 0, behavior: 'auto' }); window.requestAnimationFrame(() => { - episodeButtonRefs.current[episodeIndex]?.focus({ preventScroll: true }); + focusEpisodePanelElement(episodeButtonRefs.current[episodeIndex]); }); }, [episodeIndex, showEpisodes]); + useEffect(() => { + if (!showEpisodes) return; + + let raf = window.requestAnimationFrame(() => { + focusEpisodePanelInitial(episodesPanelRef.current, episodeIndex); + }); + + const keepFocusInsideEpisodesPanel = (event: Event) => { + const panel = episodesPanelRef.current; + const target = event.target; + if (!panel || (target instanceof Node && panel.contains(target))) return; + + if (raf) window.cancelAnimationFrame(raf); + raf = window.requestAnimationFrame(() => { + focusEpisodePanelInitial(episodesPanelRef.current, episodeIndex); + }); + }; + + document.addEventListener('focusin', keepFocusInsideEpisodesPanel, true); + + return () => { + if (raf) window.cancelAnimationFrame(raf); + document.removeEventListener( + 'focusin', + keepFocusInsideEpisodesPanel, + true + ); + }; + }, [episodeIndex, showEpisodes]); + useEffect(() => { if (!showEpisodes) { window.scrollTo({ top: 0, left: 0, behavior: 'auto' }); @@ -1354,7 +1614,11 @@ function TVPlayClient() { {showEpisodes && ( -