webtv优化选集面板聚焦
This commit is contained in:
+270
-2
@@ -71,6 +71,11 @@ const TV_DANMAKU_SETTINGS_KEY = 'tv_danmaku_settings';
|
|||||||
const TV_VOLUME_KEY = 'tv_player_volume';
|
const TV_VOLUME_KEY = 'tv_player_volume';
|
||||||
const TV_MUTED_KEY = 'tv_player_muted';
|
const TV_MUTED_KEY = 'tv_player_muted';
|
||||||
const REMOTE_KEY_DEDUPE_MS = 350;
|
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 = {
|
type TVDanmakuSettings = {
|
||||||
fontSize: number;
|
fontSize: number;
|
||||||
@@ -215,6 +220,198 @@ function moveFocusWithinScope(scope: HTMLElement, direction: 'up' | 'down') {
|
|||||||
elements[nextIndex]?.focus({ preventScroll: true });
|
elements[nextIndex]?.focus({ preventScroll: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getEpisodePanelGroupElements(
|
||||||
|
panel: HTMLElement,
|
||||||
|
group: TVEpisodeFocusGroup
|
||||||
|
) {
|
||||||
|
return Array.from(
|
||||||
|
panel.querySelectorAll<HTMLElement>(
|
||||||
|
`[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<HTMLElement>(
|
||||||
|
`[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<HTMLElement>('[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() {
|
function TVPlayClient() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
@@ -287,6 +484,7 @@ function TVPlayClient() {
|
|||||||
} | null>(null);
|
} | null>(null);
|
||||||
const [time, setTime] = useState({ current: 0, duration: 0 });
|
const [time, setTime] = useState({ current: 0, duration: 0 });
|
||||||
const timeRef = useRef({ current: 0, duration: 0 });
|
const timeRef = useRef({ current: 0, duration: 0 });
|
||||||
|
const episodesPanelRef = useRef<HTMLElement | null>(null);
|
||||||
const episodeButtonRefs = useRef<Record<number, HTMLButtonElement | null>>(
|
const episodeButtonRefs = useRef<Record<number, HTMLButtonElement | null>>(
|
||||||
{}
|
{}
|
||||||
);
|
);
|
||||||
@@ -907,6 +1105,38 @@ function TVPlayClient() {
|
|||||||
return;
|
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 (
|
if (
|
||||||
!showPanel &&
|
!showPanel &&
|
||||||
!showEpisodes &&
|
!showEpisodes &&
|
||||||
@@ -974,10 +1204,40 @@ function TVPlayClient() {
|
|||||||
setEpisodePage(targetPage);
|
setEpisodePage(targetPage);
|
||||||
window.scrollTo({ top: 0, left: 0, behavior: 'auto' });
|
window.scrollTo({ top: 0, left: 0, behavior: 'auto' });
|
||||||
window.requestAnimationFrame(() => {
|
window.requestAnimationFrame(() => {
|
||||||
episodeButtonRefs.current[episodeIndex]?.focus({ preventScroll: true });
|
focusEpisodePanelElement(episodeButtonRefs.current[episodeIndex]);
|
||||||
});
|
});
|
||||||
}, [episodeIndex, showEpisodes]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
if (!showEpisodes) {
|
if (!showEpisodes) {
|
||||||
window.scrollTo({ top: 0, left: 0, behavior: 'auto' });
|
window.scrollTo({ top: 0, left: 0, behavior: 'auto' });
|
||||||
@@ -1354,7 +1614,11 @@ function TVPlayClient() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{showEpisodes && (
|
{showEpisodes && (
|
||||||
<aside className='absolute bottom-40 right-8 max-h-[55vh] w-[560px] overflow-y-auto rounded-[34px] border border-white/10 bg-slate-950/92 p-6 shadow-2xl shadow-black/70 backdrop-blur-2xl'>
|
<aside
|
||||||
|
ref={episodesPanelRef}
|
||||||
|
data-tv-episode-panel
|
||||||
|
className='absolute bottom-40 right-8 max-h-[55vh] w-[560px] overflow-y-auto rounded-[34px] border border-white/10 bg-slate-950/92 p-6 shadow-2xl shadow-black/70 backdrop-blur-2xl'
|
||||||
|
>
|
||||||
<h2 className='mb-5 flex items-center gap-3 text-3xl font-black'>
|
<h2 className='mb-5 flex items-center gap-3 text-3xl font-black'>
|
||||||
<Layers className='h-8 w-8 text-rose-500' />
|
<Layers className='h-8 w-8 text-rose-500' />
|
||||||
选集与线路
|
选集与线路
|
||||||
@@ -1368,6 +1632,7 @@ function TVPlayClient() {
|
|||||||
key={`${item.source}-${item.id}`}
|
key={`${item.source}-${item.id}`}
|
||||||
onClick={() => switchSource(item)}
|
onClick={() => switchSource(item)}
|
||||||
data-tv-player-control
|
data-tv-player-control
|
||||||
|
data-tv-episode-focus-group='sources'
|
||||||
className={`tv-focusable shrink-0 cursor-pointer rounded-2xl px-5 py-3 text-xl font-bold outline-none focus:ring-4 focus:ring-rose-300 ${
|
className={`tv-focusable shrink-0 cursor-pointer rounded-2xl px-5 py-3 text-xl font-bold outline-none focus:ring-4 focus:ring-rose-300 ${
|
||||||
detail.source === item.source && detail.id === item.id
|
detail.source === item.source && detail.id === item.id
|
||||||
? 'bg-rose-600'
|
? 'bg-rose-600'
|
||||||
@@ -1389,6 +1654,7 @@ function TVPlayClient() {
|
|||||||
key={page}
|
key={page}
|
||||||
onClick={() => setEpisodePage(page)}
|
onClick={() => setEpisodePage(page)}
|
||||||
data-tv-player-control
|
data-tv-player-control
|
||||||
|
data-tv-episode-focus-group='pages'
|
||||||
className={`tv-focusable shrink-0 cursor-pointer rounded-2xl px-5 py-3 text-xl font-black outline-none focus:ring-4 focus:ring-rose-300 ${
|
className={`tv-focusable shrink-0 cursor-pointer rounded-2xl px-5 py-3 text-xl font-black outline-none focus:ring-4 focus:ring-rose-300 ${
|
||||||
page === episodePage ? 'bg-rose-600' : 'bg-white/10'
|
page === episodePage ? 'bg-rose-600' : 'bg-white/10'
|
||||||
}`}
|
}`}
|
||||||
@@ -1408,6 +1674,8 @@ function TVPlayClient() {
|
|||||||
}}
|
}}
|
||||||
onClick={() => switchEpisode(index)}
|
onClick={() => switchEpisode(index)}
|
||||||
data-tv-player-control
|
data-tv-player-control
|
||||||
|
data-tv-episode-focus-group='episodes'
|
||||||
|
data-tv-episode-index={index}
|
||||||
className={`tv-focusable min-h-16 cursor-pointer rounded-2xl px-3 py-3 text-lg font-black outline-none focus:ring-4 focus:ring-rose-300 ${
|
className={`tv-focusable min-h-16 cursor-pointer rounded-2xl px-3 py-3 text-lg font-black outline-none focus:ring-4 focus:ring-rose-300 ${
|
||||||
index === episodeIndex ? 'bg-rose-600' : 'bg-white/10'
|
index === episodeIndex ? 'bg-rose-600' : 'bg-white/10'
|
||||||
}`}
|
}`}
|
||||||
|
|||||||
@@ -28,11 +28,18 @@ const focusableSelector = [
|
|||||||
function isVisible(element: HTMLElement) {
|
function isVisible(element: HTMLElement) {
|
||||||
const rect = element.getBoundingClientRect();
|
const rect = element.getBoundingClientRect();
|
||||||
const style = window.getComputedStyle(element);
|
const style = window.getComputedStyle(element);
|
||||||
return rect.width > 0 && rect.height > 0 && style.visibility !== 'hidden' && style.display !== 'none';
|
return (
|
||||||
|
rect.width > 0 &&
|
||||||
|
rect.height > 0 &&
|
||||||
|
style.visibility !== 'hidden' &&
|
||||||
|
style.display !== 'none'
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getScopedFocusableElements() {
|
function getScopedFocusableElements() {
|
||||||
const scope = document.querySelector<HTMLElement>('[data-tv-focus-scope="active"]');
|
const scope = document.querySelector<HTMLElement>(
|
||||||
|
'[data-tv-focus-scope="active"]'
|
||||||
|
);
|
||||||
if (scope) {
|
if (scope) {
|
||||||
return Array.from(scope.querySelectorAll<HTMLElement>(focusableSelector))
|
return Array.from(scope.querySelectorAll<HTMLElement>(focusableSelector))
|
||||||
.filter((element) => !element.closest('[data-tv-remote]'))
|
.filter((element) => !element.closest('[data-tv-remote]'))
|
||||||
@@ -46,7 +53,9 @@ function getScopedFocusableElements() {
|
|||||||
function getFocusableElements() {
|
function getFocusableElements() {
|
||||||
const scopedElements = getScopedFocusableElements();
|
const scopedElements = getScopedFocusableElements();
|
||||||
if (scopedElements) {
|
if (scopedElements) {
|
||||||
return Array.from(new Set([...getTopNavigationElements(), ...scopedElements]));
|
return Array.from(
|
||||||
|
new Set([...getTopNavigationElements(), ...scopedElements])
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return Array.from(document.querySelectorAll<HTMLElement>(focusableSelector))
|
return Array.from(document.querySelectorAll<HTMLElement>(focusableSelector))
|
||||||
@@ -59,8 +68,12 @@ function getScrollableParent(element: HTMLElement) {
|
|||||||
let current = element.parentElement;
|
let current = element.parentElement;
|
||||||
while (current && current !== document.body) {
|
while (current && current !== document.body) {
|
||||||
const style = window.getComputedStyle(current);
|
const style = window.getComputedStyle(current);
|
||||||
const canScrollY = /(auto|scroll)/.test(style.overflowY) && current.scrollHeight > current.clientHeight;
|
const canScrollY =
|
||||||
const canScrollX = /(auto|scroll)/.test(style.overflowX) && current.scrollWidth > current.clientWidth;
|
/(auto|scroll)/.test(style.overflowY) &&
|
||||||
|
current.scrollHeight > current.clientHeight;
|
||||||
|
const canScrollX =
|
||||||
|
/(auto|scroll)/.test(style.overflowX) &&
|
||||||
|
current.scrollWidth > current.clientWidth;
|
||||||
if (canScrollY || canScrollX) return current;
|
if (canScrollY || canScrollX) return current;
|
||||||
current = current.parentElement;
|
current = current.parentElement;
|
||||||
}
|
}
|
||||||
@@ -135,7 +148,9 @@ function focusNearestTopNavigationElement(active: HTMLElement) {
|
|||||||
if (!(element instanceof HTMLAnchorElement)) return false;
|
if (!(element instanceof HTMLAnchorElement)) return false;
|
||||||
const href = element.getAttribute('href');
|
const href = element.getAttribute('href');
|
||||||
if (!href) return false;
|
if (!href) return false;
|
||||||
return href === '/tv' ? pathname === '/tv' : pathname === href || pathname.startsWith(`${href}/`);
|
return href === '/tv'
|
||||||
|
? pathname === '/tv'
|
||||||
|
: pathname === href || pathname.startsWith(`${href}/`);
|
||||||
});
|
});
|
||||||
|
|
||||||
if (activeNavigationElement) {
|
if (activeNavigationElement) {
|
||||||
@@ -145,28 +160,35 @@ function focusNearestTopNavigationElement(active: HTMLElement) {
|
|||||||
|
|
||||||
const activeRect = active.getBoundingClientRect();
|
const activeRect = active.getBoundingClientRect();
|
||||||
const activeCenterX = activeRect.left + activeRect.width / 2;
|
const activeCenterX = activeRect.left + activeRect.width / 2;
|
||||||
const bestNavigationElement = topNavigationElements.reduce<HTMLElement | null>((best, element) => {
|
const bestNavigationElement =
|
||||||
if (!best) return element;
|
topNavigationElements.reduce<HTMLElement | null>((best, element) => {
|
||||||
const rect = element.getBoundingClientRect();
|
if (!best) return element;
|
||||||
const bestRect = best.getBoundingClientRect();
|
const rect = element.getBoundingClientRect();
|
||||||
const distance = Math.abs(rect.left + rect.width / 2 - activeCenterX);
|
const bestRect = best.getBoundingClientRect();
|
||||||
const bestDistance = Math.abs(bestRect.left + bestRect.width / 2 - activeCenterX);
|
const distance = Math.abs(rect.left + rect.width / 2 - activeCenterX);
|
||||||
return distance < bestDistance ? element : best;
|
const bestDistance = Math.abs(
|
||||||
}, null);
|
bestRect.left + bestRect.width / 2 - activeCenterX
|
||||||
|
);
|
||||||
|
return distance < bestDistance ? element : best;
|
||||||
|
}, null);
|
||||||
|
|
||||||
if (!bestNavigationElement) return false;
|
if (!bestNavigationElement) return false;
|
||||||
focusElement(bestNavigationElement);
|
focusElement(bestNavigationElement);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function moveTopNavigationFocus(active: HTMLElement, direction: 'left' | 'right') {
|
function moveTopNavigationFocus(
|
||||||
|
active: HTMLElement,
|
||||||
|
direction: 'left' | 'right'
|
||||||
|
) {
|
||||||
const topNavigationElements = getTopNavigationElements();
|
const topNavigationElements = getTopNavigationElements();
|
||||||
const currentIndex = topNavigationElements.indexOf(active);
|
const currentIndex = topNavigationElements.indexOf(active);
|
||||||
if (currentIndex === -1) return false;
|
if (currentIndex === -1) return false;
|
||||||
|
|
||||||
const nextIndex = direction === 'right'
|
const nextIndex =
|
||||||
? Math.min(currentIndex + 1, topNavigationElements.length - 1)
|
direction === 'right'
|
||||||
: Math.max(currentIndex - 1, 0);
|
? Math.min(currentIndex + 1, topNavigationElements.length - 1)
|
||||||
|
: Math.max(currentIndex - 1, 0);
|
||||||
|
|
||||||
if (nextIndex === currentIndex) return true;
|
if (nextIndex === currentIndex) return true;
|
||||||
|
|
||||||
@@ -174,19 +196,25 @@ function moveTopNavigationFocus(active: HTMLElement, direction: 'left' | 'right'
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function moveHorizontalRowFocus(active: HTMLElement, direction: 'left' | 'right') {
|
function moveHorizontalRowFocus(
|
||||||
|
active: HTMLElement,
|
||||||
|
direction: 'left' | 'right'
|
||||||
|
) {
|
||||||
const row = active.closest<HTMLElement>('[data-tv-focus-row="horizontal"]');
|
const row = active.closest<HTMLElement>('[data-tv-focus-row="horizontal"]');
|
||||||
if (!row) return false;
|
if (!row) return false;
|
||||||
|
|
||||||
const rowElements = Array.from(row.querySelectorAll<HTMLElement>(focusableSelector))
|
const rowElements = Array.from(
|
||||||
|
row.querySelectorAll<HTMLElement>(focusableSelector)
|
||||||
|
)
|
||||||
.filter((element) => !element.closest('[data-tv-no-focus="true"]'))
|
.filter((element) => !element.closest('[data-tv-no-focus="true"]'))
|
||||||
.filter(isVisible);
|
.filter(isVisible);
|
||||||
const currentIndex = rowElements.indexOf(active);
|
const currentIndex = rowElements.indexOf(active);
|
||||||
if (currentIndex === -1) return false;
|
if (currentIndex === -1) return false;
|
||||||
|
|
||||||
const nextIndex = direction === 'right'
|
const nextIndex =
|
||||||
? Math.min(currentIndex + 1, rowElements.length - 1)
|
direction === 'right'
|
||||||
: Math.max(currentIndex - 1, 0);
|
? Math.min(currentIndex + 1, rowElements.length - 1)
|
||||||
|
: Math.max(currentIndex - 1, 0);
|
||||||
|
|
||||||
if (nextIndex === currentIndex) return true;
|
if (nextIndex === currentIndex) return true;
|
||||||
|
|
||||||
@@ -197,7 +225,11 @@ function moveHorizontalRowFocus(active: HTMLElement, direction: 'left' | 'right'
|
|||||||
function focusElement(element: HTMLElement) {
|
function focusElement(element: HTMLElement) {
|
||||||
element.focus({ preventScroll: true });
|
element.focus({ preventScroll: true });
|
||||||
|
|
||||||
const isInFixedChrome = Boolean(element.closest('header, [data-tv-remote], [data-tv-player-control], [data-tv-player-root]'));
|
const isInFixedChrome = Boolean(
|
||||||
|
element.closest(
|
||||||
|
'header, [data-tv-remote], [data-tv-player-control], [data-tv-player-root]'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
// 播放页浮层是 fixed/absolute,不能 scrollIntoView,否则会把全屏播放器滚出视口。
|
// 播放页浮层是 fixed/absolute,不能 scrollIntoView,否则会把全屏播放器滚出视口。
|
||||||
if (!isInFixedChrome) {
|
if (!isInFixedChrome) {
|
||||||
@@ -222,22 +254,30 @@ function focusElement(element: HTMLElement) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function moveSpatialFocus(direction: 'up' | 'down' | 'left' | 'right', lastFocused?: HTMLElement | null) {
|
function moveSpatialFocus(
|
||||||
|
direction: 'up' | 'down' | 'left' | 'right',
|
||||||
|
lastFocused?: HTMLElement | null
|
||||||
|
) {
|
||||||
const elements = getFocusableElements();
|
const elements = getFocusableElements();
|
||||||
if (elements.length === 0) return;
|
if (elements.length === 0) return;
|
||||||
|
|
||||||
const active = document.activeElement instanceof HTMLElement && !document.activeElement.closest('[data-tv-remote]')
|
const active =
|
||||||
? document.activeElement
|
document.activeElement instanceof HTMLElement &&
|
||||||
: lastFocused && document.body.contains(lastFocused)
|
!document.activeElement.closest('[data-tv-remote]')
|
||||||
? lastFocused
|
? document.activeElement
|
||||||
: null;
|
: lastFocused && document.body.contains(lastFocused)
|
||||||
|
? lastFocused
|
||||||
|
: null;
|
||||||
|
|
||||||
if (!active || !elements.includes(active)) {
|
if (!active || !elements.includes(active)) {
|
||||||
focusElement(elements[0]);
|
focusElement(elements[0]);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ((direction === 'left' || direction === 'right') && isTopNavigationElement(active)) {
|
if (
|
||||||
|
(direction === 'left' || direction === 'right') &&
|
||||||
|
isTopNavigationElement(active)
|
||||||
|
) {
|
||||||
if (moveTopNavigationFocus(active, direction)) return;
|
if (moveTopNavigationFocus(active, direction)) return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -282,15 +322,24 @@ function moveSpatialFocus(direction: 'up' | 'down' | 'left' | 'right', lastFocus
|
|||||||
const dy = ty - cy;
|
const dy = ty - cy;
|
||||||
|
|
||||||
const inDirection =
|
const inDirection =
|
||||||
direction === 'right' ? dx > 8 && Math.abs(dx) >= Math.abs(dy) * 0.25 :
|
direction === 'right'
|
||||||
direction === 'left' ? dx < -8 && Math.abs(dx) >= Math.abs(dy) * 0.25 :
|
? dx > 8 && Math.abs(dx) >= Math.abs(dy) * 0.25
|
||||||
direction === 'down' ? dy > 8 && Math.abs(dy) >= Math.abs(dx) * 0.25 :
|
: direction === 'left'
|
||||||
dy < -8 && Math.abs(dy) >= Math.abs(dx) * 0.25;
|
? dx < -8 && Math.abs(dx) >= Math.abs(dy) * 0.25
|
||||||
|
: direction === 'down'
|
||||||
|
? dy > 8 && Math.abs(dy) >= Math.abs(dx) * 0.25
|
||||||
|
: dy < -8 && Math.abs(dy) >= Math.abs(dx) * 0.25;
|
||||||
|
|
||||||
if (!inDirection) continue;
|
if (!inDirection) continue;
|
||||||
|
|
||||||
const primary = direction === 'left' || direction === 'right' ? Math.abs(dx) : Math.abs(dy);
|
const primary =
|
||||||
const secondary = direction === 'left' || direction === 'right' ? Math.abs(dy) : Math.abs(dx);
|
direction === 'left' || direction === 'right'
|
||||||
|
? Math.abs(dx)
|
||||||
|
: Math.abs(dy);
|
||||||
|
const secondary =
|
||||||
|
direction === 'left' || direction === 'right'
|
||||||
|
? Math.abs(dy)
|
||||||
|
: Math.abs(dx);
|
||||||
const score = primary + secondary * 2.4;
|
const score = primary + secondary * 2.4;
|
||||||
|
|
||||||
candidates.push({ element, score });
|
candidates.push({ element, score });
|
||||||
@@ -298,14 +347,22 @@ function moveSpatialFocus(direction: 'up' | 'down' | 'left' | 'right', lastFocus
|
|||||||
|
|
||||||
let eligibleCandidates = candidates;
|
let eligibleCandidates = candidates;
|
||||||
if (direction === 'up' && !isTopNavigationElement(active)) {
|
if (direction === 'up' && !isTopNavigationElement(active)) {
|
||||||
const contentCandidates = candidates.filter(({ element }) => !isTopNavigationElement(element));
|
const contentCandidates = candidates.filter(
|
||||||
|
({ element }) => !isTopNavigationElement(element)
|
||||||
|
);
|
||||||
if (contentCandidates.length > 0) {
|
if (contentCandidates.length > 0) {
|
||||||
eligibleCandidates = contentCandidates;
|
eligibleCandidates = contentCandidates;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const best = eligibleCandidates.reduce<{ element: HTMLElement; score: number } | null>(
|
const best = eligibleCandidates.reduce<{
|
||||||
(currentBest, candidate) => (!currentBest || candidate.score < currentBest.score ? candidate : currentBest),
|
element: HTMLElement;
|
||||||
|
score: number;
|
||||||
|
} | null>(
|
||||||
|
(currentBest, candidate) =>
|
||||||
|
!currentBest || candidate.score < currentBest.score
|
||||||
|
? candidate
|
||||||
|
: currentBest,
|
||||||
null
|
null
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -382,7 +439,10 @@ export default function TVVirtualRemote() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onFocusIn = (event: FocusEvent) => {
|
const onFocusIn = (event: FocusEvent) => {
|
||||||
const target = event.target;
|
const target = event.target;
|
||||||
if (target instanceof HTMLElement && !target.closest('[data-tv-remote]')) {
|
if (
|
||||||
|
target instanceof HTMLElement &&
|
||||||
|
!target.closest('[data-tv-remote]')
|
||||||
|
) {
|
||||||
lastFocusedRef.current = target;
|
lastFocusedRef.current = target;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -396,9 +456,23 @@ export default function TVVirtualRemote() {
|
|||||||
|
|
||||||
if (event.defaultPrevented) return;
|
if (event.defaultPrevented) return;
|
||||||
|
|
||||||
if (event.key === 'ArrowUp' || event.key === 'ArrowDown' || event.key === 'ArrowLeft' || event.key === 'ArrowRight') {
|
if (
|
||||||
|
event.key === 'ArrowUp' ||
|
||||||
|
event.key === 'ArrowDown' ||
|
||||||
|
event.key === 'ArrowLeft' ||
|
||||||
|
event.key === 'ArrowRight'
|
||||||
|
) {
|
||||||
|
// 播放页选集弹窗有自己的方向键导航和焦点陷阱;
|
||||||
|
// 这里如果也执行全局空间焦点移动,会导致一次按键移动两格。
|
||||||
|
if (document.querySelector('[data-tv-episode-panel]')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const active = document.activeElement;
|
const active = document.activeElement;
|
||||||
if (active instanceof HTMLElement && active.closest('[data-tv-danmaku-settings]')) {
|
if (
|
||||||
|
active instanceof HTMLElement &&
|
||||||
|
active.closest('[data-tv-danmaku-settings]')
|
||||||
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -412,24 +486,38 @@ export default function TVVirtualRemote() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (active instanceof HTMLInputElement && active.type === 'range' && active.closest('[data-tv-no-focus="true"]')) {
|
if (
|
||||||
|
active instanceof HTMLInputElement &&
|
||||||
|
active.type === 'range' &&
|
||||||
|
active.closest('[data-tv-no-focus="true"]')
|
||||||
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const direction = event.key.replace('Arrow', '').toLowerCase() as 'up' | 'down' | 'left' | 'right';
|
const direction = event.key.replace('Arrow', '').toLowerCase() as
|
||||||
|
| 'up'
|
||||||
|
| 'down'
|
||||||
|
| 'left'
|
||||||
|
| 'right';
|
||||||
moveSpatialFocus(direction, lastFocusedRef.current);
|
moveSpatialFocus(direction, lastFocusedRef.current);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (event.key === 'Enter') {
|
if (event.key === 'Enter') {
|
||||||
const playerRoot = document.querySelector<HTMLElement>('[data-tv-player-root]');
|
const playerRoot = document.querySelector<HTMLElement>(
|
||||||
|
'[data-tv-player-root]'
|
||||||
|
);
|
||||||
if (playerRoot?.dataset.tvControlsOpen === 'false') {
|
if (playerRoot?.dataset.tvControlsOpen === 'false') {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const active = document.activeElement;
|
const active = document.activeElement;
|
||||||
if (active instanceof HTMLElement && !active.closest('input, textarea, select') && !active.closest('[data-tv-remote]')) {
|
if (
|
||||||
|
active instanceof HTMLElement &&
|
||||||
|
!active.closest('input, textarea, select') &&
|
||||||
|
!active.closest('[data-tv-remote]')
|
||||||
|
) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
activateFocused();
|
activateFocused();
|
||||||
}
|
}
|
||||||
@@ -464,46 +552,93 @@ export default function TVVirtualRemote() {
|
|||||||
if (!open) return null;
|
if (!open) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside data-tv-remote className='fixed bottom-6 right-6 z-[80] w-[280px] rounded-[34px] border border-white/10 bg-slate-950/88 p-5 text-white shadow-2xl shadow-black/70 backdrop-blur-2xl'>
|
<aside
|
||||||
|
data-tv-remote
|
||||||
|
className='fixed bottom-6 right-6 z-[80] w-[280px] rounded-[34px] border border-white/10 bg-slate-950/88 p-5 text-white shadow-2xl shadow-black/70 backdrop-blur-2xl'
|
||||||
|
>
|
||||||
<div className='mb-4 flex items-center justify-between'>
|
<div className='mb-4 flex items-center justify-between'>
|
||||||
<div>
|
<div>
|
||||||
<div className='text-xl font-black'>虚拟遥控器</div>
|
<div className='text-xl font-black'>虚拟遥控器</div>
|
||||||
<div className='text-sm text-slate-400'>F1 打开 / 关闭</div>
|
<div className='text-sm text-slate-400'>F1 打开 / 关闭</div>
|
||||||
</div>
|
</div>
|
||||||
<RemoteButton label='关闭遥控器' onClick={() => setOpen(false)} className='h-11 w-11 rounded-full bg-rose-600/90 hover:bg-rose-500'>
|
<RemoteButton
|
||||||
|
label='关闭遥控器'
|
||||||
|
onClick={() => setOpen(false)}
|
||||||
|
className='h-11 w-11 rounded-full bg-rose-600/90 hover:bg-rose-500'
|
||||||
|
>
|
||||||
<Power className='h-5 w-5' />
|
<Power className='h-5 w-5' />
|
||||||
</RemoteButton>
|
</RemoteButton>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='grid grid-cols-3 gap-3'>
|
<div className='grid grid-cols-3 gap-3'>
|
||||||
<RemoteButton label='返回' onClick={() => fireTVRemoteKey('back')} className='h-14'>
|
<RemoteButton
|
||||||
|
label='返回'
|
||||||
|
onClick={() => fireTVRemoteKey('back')}
|
||||||
|
className='h-14'
|
||||||
|
>
|
||||||
<RotateCcw className='h-6 w-6' />
|
<RotateCcw className='h-6 w-6' />
|
||||||
</RemoteButton>
|
</RemoteButton>
|
||||||
<RemoteButton label='主页' onClick={() => fireTVRemoteKey('home')} className='h-14'>
|
<RemoteButton
|
||||||
|
label='主页'
|
||||||
|
onClick={() => fireTVRemoteKey('home')}
|
||||||
|
className='h-14'
|
||||||
|
>
|
||||||
<Home className='h-6 w-6' />
|
<Home className='h-6 w-6' />
|
||||||
</RemoteButton>
|
</RemoteButton>
|
||||||
<RemoteButton label='菜单' onClick={() => fireTVRemoteKey('menu')} className='h-14'>
|
<RemoteButton
|
||||||
|
label='菜单'
|
||||||
|
onClick={() => fireTVRemoteKey('menu')}
|
||||||
|
className='h-14'
|
||||||
|
>
|
||||||
<Menu className='h-6 w-6' />
|
<Menu className='h-6 w-6' />
|
||||||
</RemoteButton>
|
</RemoteButton>
|
||||||
|
|
||||||
<div />
|
<div />
|
||||||
<RemoteButton label='上' onClick={() => fireTVRemoteKey('up')} onRepeat={() => fireTVRemoteKey('up', true)} repeatable className='h-16'>
|
<RemoteButton
|
||||||
|
label='上'
|
||||||
|
onClick={() => fireTVRemoteKey('up')}
|
||||||
|
onRepeat={() => fireTVRemoteKey('up', true)}
|
||||||
|
repeatable
|
||||||
|
className='h-16'
|
||||||
|
>
|
||||||
<ChevronUp className='h-9 w-9' />
|
<ChevronUp className='h-9 w-9' />
|
||||||
</RemoteButton>
|
</RemoteButton>
|
||||||
<div />
|
<div />
|
||||||
|
|
||||||
<RemoteButton label='左' onClick={() => fireTVRemoteKey('left')} onRepeat={() => fireTVRemoteKey('left', true)} repeatable className='h-16'>
|
<RemoteButton
|
||||||
|
label='左'
|
||||||
|
onClick={() => fireTVRemoteKey('left')}
|
||||||
|
onRepeat={() => fireTVRemoteKey('left', true)}
|
||||||
|
repeatable
|
||||||
|
className='h-16'
|
||||||
|
>
|
||||||
<ChevronLeft className='h-9 w-9' />
|
<ChevronLeft className='h-9 w-9' />
|
||||||
</RemoteButton>
|
</RemoteButton>
|
||||||
<RemoteButton label='确认' onClick={() => fireTVRemoteKey('ok')} className='h-16 rounded-full bg-white text-black hover:bg-slate-200'>
|
<RemoteButton
|
||||||
|
label='确认'
|
||||||
|
onClick={() => fireTVRemoteKey('ok')}
|
||||||
|
className='h-16 rounded-full bg-white text-black hover:bg-slate-200'
|
||||||
|
>
|
||||||
<CornerDownLeft className='h-8 w-8' />
|
<CornerDownLeft className='h-8 w-8' />
|
||||||
</RemoteButton>
|
</RemoteButton>
|
||||||
<RemoteButton label='右' onClick={() => fireTVRemoteKey('right')} onRepeat={() => fireTVRemoteKey('right', true)} repeatable className='h-16'>
|
<RemoteButton
|
||||||
|
label='右'
|
||||||
|
onClick={() => fireTVRemoteKey('right')}
|
||||||
|
onRepeat={() => fireTVRemoteKey('right', true)}
|
||||||
|
repeatable
|
||||||
|
className='h-16'
|
||||||
|
>
|
||||||
<ChevronRight className='h-9 w-9' />
|
<ChevronRight className='h-9 w-9' />
|
||||||
</RemoteButton>
|
</RemoteButton>
|
||||||
|
|
||||||
<div />
|
<div />
|
||||||
<RemoteButton label='下' onClick={() => fireTVRemoteKey('down')} onRepeat={() => fireTVRemoteKey('down', true)} repeatable className='h-16'>
|
<RemoteButton
|
||||||
|
label='下'
|
||||||
|
onClick={() => fireTVRemoteKey('down')}
|
||||||
|
onRepeat={() => fireTVRemoteKey('down', true)}
|
||||||
|
repeatable
|
||||||
|
className='h-16'
|
||||||
|
>
|
||||||
<ChevronDown className='h-9 w-9' />
|
<ChevronDown className='h-9 w-9' />
|
||||||
</RemoteButton>
|
</RemoteButton>
|
||||||
<div />
|
<div />
|
||||||
|
|||||||
Reference in New Issue
Block a user