tv模式初版
This commit is contained in:
@@ -0,0 +1,93 @@
|
||||
'use client';
|
||||
|
||||
import { Loader2, PlayCircle } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import TVLayout from './TVLayout';
|
||||
import TVRow from './TVRow';
|
||||
import { TVItem, TVSection } from './types';
|
||||
|
||||
const fallbackPosters = [
|
||||
'https://images.unsplash.com/photo-1489599849927-2ee91cede3ba?auto=format&fit=crop&w=500&q=80',
|
||||
'https://images.unsplash.com/photo-1524985069026-dd778a71c7b4?auto=format&fit=crop&w=500&q=80',
|
||||
'https://images.unsplash.com/photo-1517604931442-7e0c8ed2963c?auto=format&fit=crop&w=500&q=80',
|
||||
];
|
||||
|
||||
async function loadDouban(kind: 'movie' | 'tv', tag: string, type: TVItem['type']): Promise<TVItem[]> {
|
||||
const res = await fetch(`/api/douban?type=${kind}&tag=${encodeURIComponent(tag)}&pageSize=12`, { cache: 'no-store' });
|
||||
if (!res.ok) throw new Error('load failed');
|
||||
const data = await res.json();
|
||||
return (data.list || []).map((item: TVItem) => ({ ...item, type }));
|
||||
}
|
||||
|
||||
export default function TVBrowsePage({
|
||||
title,
|
||||
subtitle,
|
||||
heroTitle,
|
||||
heroSubtitle,
|
||||
sections,
|
||||
}: {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
heroTitle?: string;
|
||||
heroSubtitle?: string;
|
||||
sections: Array<{ title: string; subtitle?: string; kind: 'movie' | 'tv'; tag: string; type: TVItem['type']; href?: string }>;
|
||||
}) {
|
||||
const [rows, setRows] = useState<TVSection[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
Promise.allSettled(sections.map(async (section) => ({
|
||||
title: section.title,
|
||||
subtitle: section.subtitle,
|
||||
href: section.href,
|
||||
items: await loadDouban(section.kind, section.tag, section.type),
|
||||
}))).then((results) => {
|
||||
if (!alive) return;
|
||||
const next = results.reduce<TVSection[]>((acc, result) => {
|
||||
if (result.status === 'fulfilled' && result.value.items.length > 0) {
|
||||
acc.push(result.value);
|
||||
}
|
||||
return acc;
|
||||
}, []);
|
||||
setRows(next);
|
||||
setLoading(false);
|
||||
});
|
||||
return () => { alive = false; };
|
||||
}, [sections]);
|
||||
|
||||
const hero = rows[0]?.items[0];
|
||||
|
||||
return (
|
||||
<TVLayout>
|
||||
<section className='relative overflow-hidden rounded-[42px] border border-white/10 bg-slate-950/70 p-10 shadow-2xl shadow-black/60'>
|
||||
<div className='absolute inset-0 opacity-35'>
|
||||
{hero?.poster ? (
|
||||
<img src={hero.poster} alt='' className='h-full w-full object-cover blur-sm' />
|
||||
) : (
|
||||
<img src={fallbackPosters[0]} alt='' className='h-full w-full object-cover blur-sm' />
|
||||
)}
|
||||
<div className='absolute inset-0 bg-gradient-to-r from-black via-black/80 to-transparent' />
|
||||
</div>
|
||||
<div className='relative max-w-4xl py-12'>
|
||||
<p className='mb-4 inline-flex rounded-full bg-rose-600 px-5 py-2 text-xl font-bold text-white'>TV 专用大屏模式</p>
|
||||
<h1 className='text-7xl font-black tracking-tight text-white drop-shadow-2xl'>{heroTitle || title}</h1>
|
||||
<p className='mt-5 max-w-3xl text-2xl leading-relaxed text-slate-200'>{heroSubtitle || subtitle}</p>
|
||||
<div className='mt-9 inline-flex items-center gap-3 rounded-2xl bg-white px-7 py-4 text-2xl font-black text-black'>
|
||||
<PlayCircle className='h-8 w-8 text-rose-600' />
|
||||
遥控器 OK 键开始浏览
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{loading ? (
|
||||
<div className='mt-16 flex items-center justify-center gap-4 text-2xl text-slate-300'>
|
||||
<Loader2 className='h-8 w-8 animate-spin' /> 正在加载大屏内容...
|
||||
</div>
|
||||
) : (
|
||||
rows.map((section) => <TVRow key={section.title} section={section} />)
|
||||
)}
|
||||
</TVLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
'use client';
|
||||
|
||||
import { Play, Star } from 'lucide-react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import { processImageUrl } from '@/lib/utils';
|
||||
|
||||
import { TVItem } from './types';
|
||||
|
||||
export default function TVCard({ item }: { item: TVItem }) {
|
||||
const router = useRouter();
|
||||
const poster = item.poster ? processImageUrl(item.poster) : '';
|
||||
const playUrl = item.href || `/tv/play?title=${encodeURIComponent(item.title)}${
|
||||
item.year ? `&year=${encodeURIComponent(item.year)}` : ''
|
||||
}${item.type ? `&stype=${item.type}` : ''}`;
|
||||
|
||||
return (
|
||||
<button
|
||||
type='button'
|
||||
onClick={() => router.push(playUrl)}
|
||||
className='group w-[210px] shrink-0 cursor-pointer rounded-[28px] bg-white/[0.04] p-3 text-left outline-none transition duration-200 hover:bg-white/10 focus-visible:bg-white/10 tv-focusable'
|
||||
>
|
||||
<div className='relative aspect-[2/3] overflow-hidden rounded-[22px] bg-slate-900 shadow-xl shadow-black/50 transition duration-200 group-hover:scale-[1.03] group-focus-visible:scale-[1.03]'>
|
||||
{poster ? (
|
||||
<img src={poster} alt={item.title} className='h-full w-full object-cover' />
|
||||
) : (
|
||||
<div className='flex h-full w-full items-center justify-center bg-gradient-to-br from-slate-800 to-slate-950 text-slate-500'>
|
||||
<Play className='h-14 w-14' />
|
||||
</div>
|
||||
)}
|
||||
<div className='absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/90 to-transparent p-3'>
|
||||
<div className='inline-flex items-center gap-1 rounded-full bg-black/60 px-2 py-1 text-sm text-amber-300 backdrop-blur'>
|
||||
<Star className='h-4 w-4 fill-current' />
|
||||
{item.rate || '推荐'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<h3 className='mt-3 line-clamp-1 text-[22px] font-bold text-white'>{item.title}</h3>
|
||||
<p className='mt-1 text-lg text-slate-400'>{item.year || '即刻播放'}</p>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
Film,
|
||||
Heart,
|
||||
Home,
|
||||
MonitorPlay,
|
||||
Radio,
|
||||
Search,
|
||||
Sparkles,
|
||||
Tv,
|
||||
} from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
import TVVirtualRemote from './TVVirtualRemote';
|
||||
|
||||
const navItems = [
|
||||
{ label: '搜索', href: '/tv/search', icon: Search },
|
||||
{ label: '主页', href: '/tv', icon: Home },
|
||||
{ label: '电影', href: '/tv/movie', icon: Film },
|
||||
{ label: '剧集', href: '/tv/series', icon: Tv },
|
||||
{ label: '动漫', href: '/tv/anime', icon: Sparkles },
|
||||
{ label: '综艺', href: '/tv/variety', icon: MonitorPlay },
|
||||
{ label: '直播', href: '/tv/live', icon: Radio },
|
||||
{ label: '私人影库', href: '/tv/private', icon: Heart },
|
||||
];
|
||||
|
||||
export default function TVLayout({ children }: { children: ReactNode }) {
|
||||
const pathname = usePathname();
|
||||
|
||||
return (
|
||||
<main className='min-h-screen overflow-x-hidden bg-black text-slate-50'>
|
||||
<div className='fixed inset-0 pointer-events-none bg-[radial-gradient(circle_at_20%_0%,rgba(225,29,72,0.22),transparent_34%),radial-gradient(circle_at_90%_10%,rgba(79,70,229,0.24),transparent_30%),linear-gradient(180deg,#05050b_0%,#000_55%)]' />
|
||||
<header className='fixed left-6 right-6 top-5 z-40 rounded-[28px] border border-white/10 bg-slate-950/78 px-5 py-3 shadow-2xl shadow-black/60 backdrop-blur-xl'>
|
||||
<nav className='flex items-center justify-center gap-3 overflow-x-auto overscroll-x-contain px-4 py-3 [scrollbar-width:none]'>
|
||||
{navItems.map((item) => {
|
||||
const active =
|
||||
item.href === '/tv'
|
||||
? pathname === '/tv'
|
||||
: pathname === item.href || pathname.startsWith(`${item.href}/`);
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={`group flex shrink-0 cursor-pointer items-center gap-2 rounded-2xl px-5 py-3 text-xl font-semibold outline-none transition duration-200 tv-focusable ${
|
||||
active
|
||||
? 'bg-rose-600 text-white shadow-lg shadow-rose-950/40'
|
||||
: 'bg-white/5 text-slate-300 hover:bg-white/10 hover:text-white focus:bg-white/12'
|
||||
}`}
|
||||
>
|
||||
<Icon className='h-6 w-6' />
|
||||
<span>{item.label}</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</header>
|
||||
<div className='relative z-10 px-8 pb-16 pt-32'>{children}</div>
|
||||
<TVVirtualRemote />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
'use client';
|
||||
|
||||
import { ChevronRight } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
|
||||
import TVCard from './TVCard';
|
||||
import { TVSection } from './types';
|
||||
|
||||
export default function TVRow({ section }: { section: TVSection }) {
|
||||
return (
|
||||
<section className='mt-12'>
|
||||
<div className='mb-5 flex items-end justify-between gap-4'>
|
||||
<div>
|
||||
<h2 className='text-4xl font-black tracking-tight text-white'>{section.title}</h2>
|
||||
{section.subtitle && <p className='mt-2 text-xl text-slate-400'>{section.subtitle}</p>}
|
||||
</div>
|
||||
{section.href && (
|
||||
<Link href={section.href} className='flex cursor-pointer items-center gap-1 rounded-full px-4 py-2 text-xl font-semibold text-slate-300 outline-none transition hover:bg-white/10 hover:text-white tv-focusable'>
|
||||
查看更多 <ChevronRight className='h-6 w-6' />
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
<div className='flex gap-5 overflow-x-auto px-5 py-6 [scrollbar-width:none]'>
|
||||
{section.items.map((item) => <TVCard key={`${section.title}-${item.id}`} item={item} />)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
ChevronUp,
|
||||
CornerDownLeft,
|
||||
Home,
|
||||
Menu,
|
||||
Power,
|
||||
RotateCcw,
|
||||
} from 'lucide-react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
|
||||
const focusableSelector = [
|
||||
'a[href]',
|
||||
'button:not([disabled])',
|
||||
'input:not([disabled])',
|
||||
'select:not([disabled])',
|
||||
'textarea:not([disabled])',
|
||||
'[tabindex]:not([tabindex="-1"])',
|
||||
].join(',');
|
||||
|
||||
function isVisible(element: HTMLElement) {
|
||||
const rect = element.getBoundingClientRect();
|
||||
const style = window.getComputedStyle(element);
|
||||
return rect.width > 0 && rect.height > 0 && style.visibility !== 'hidden' && style.display !== 'none';
|
||||
}
|
||||
|
||||
function getFocusableElements() {
|
||||
return Array.from(document.querySelectorAll<HTMLElement>(focusableSelector))
|
||||
.filter((element) => !element.closest('[data-tv-remote]'))
|
||||
.filter(isVisible);
|
||||
}
|
||||
|
||||
function focusElement(element: HTMLElement) {
|
||||
element.focus({ preventScroll: true });
|
||||
|
||||
const isInFixedChrome = Boolean(element.closest('header, [data-tv-remote]'));
|
||||
|
||||
// 先让浏览器处理横向滚动行,再额外修正固定顶部导航遮挡。
|
||||
element.scrollIntoView({
|
||||
block: isInFixedChrome ? 'nearest' : 'nearest',
|
||||
inline: 'nearest',
|
||||
behavior: 'smooth',
|
||||
});
|
||||
|
||||
if (!isInFixedChrome) {
|
||||
window.requestAnimationFrame(() => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
const safeTop = 150;
|
||||
const safeBottom = window.innerHeight - 96;
|
||||
|
||||
if (rect.top < safeTop) {
|
||||
window.scrollBy({ top: rect.top - safeTop, behavior: 'smooth' });
|
||||
} else if (rect.bottom > safeBottom) {
|
||||
window.scrollBy({ top: rect.bottom - safeBottom, behavior: 'smooth' });
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function moveSpatialFocus(direction: 'up' | 'down' | 'left' | 'right', lastFocused?: HTMLElement | null) {
|
||||
const elements = getFocusableElements();
|
||||
if (elements.length === 0) return;
|
||||
|
||||
const active = document.activeElement instanceof HTMLElement && !document.activeElement.closest('[data-tv-remote]')
|
||||
? document.activeElement
|
||||
: lastFocused && document.body.contains(lastFocused)
|
||||
? lastFocused
|
||||
: null;
|
||||
|
||||
if (!active || !elements.includes(active)) {
|
||||
focusElement(elements[0]);
|
||||
return;
|
||||
}
|
||||
|
||||
const current = active.getBoundingClientRect();
|
||||
const cx = current.left + current.width / 2;
|
||||
const cy = current.top + current.height / 2;
|
||||
|
||||
let best: { element: HTMLElement; score: number } | null = null;
|
||||
|
||||
for (const element of elements) {
|
||||
if (element === active) continue;
|
||||
const rect = element.getBoundingClientRect();
|
||||
const tx = rect.left + rect.width / 2;
|
||||
const ty = rect.top + rect.height / 2;
|
||||
const dx = tx - cx;
|
||||
const dy = ty - cy;
|
||||
|
||||
const inDirection =
|
||||
direction === 'right' ? dx > 8 && Math.abs(dx) >= Math.abs(dy) * 0.25 :
|
||||
direction === 'left' ? 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;
|
||||
|
||||
const primary = 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;
|
||||
|
||||
if (!best || score < best.score) {
|
||||
best = { element, score };
|
||||
}
|
||||
}
|
||||
|
||||
if (best) focusElement(best.element);
|
||||
}
|
||||
|
||||
function activateFocused() {
|
||||
const active = document.activeElement;
|
||||
if (active instanceof HTMLElement && !active.closest('[data-tv-remote]')) {
|
||||
active.click();
|
||||
}
|
||||
}
|
||||
|
||||
const keys = {
|
||||
up: { key: 'ArrowUp', code: 'ArrowUp', keyCode: 38 },
|
||||
down: { key: 'ArrowDown', code: 'ArrowDown', keyCode: 40 },
|
||||
left: { key: 'ArrowLeft', code: 'ArrowLeft', keyCode: 37 },
|
||||
right: { key: 'ArrowRight', code: 'ArrowRight', keyCode: 39 },
|
||||
ok: { key: 'Enter', code: 'Enter', keyCode: 13 },
|
||||
back: { key: 'Escape', code: 'Escape', keyCode: 27 },
|
||||
menu: { key: 'ContextMenu', code: 'ContextMenu', keyCode: 93 },
|
||||
home: { key: 'Home', code: 'Home', keyCode: 36 },
|
||||
};
|
||||
|
||||
function fireRemoteKey(name: keyof typeof keys) {
|
||||
const cfg = keys[name];
|
||||
const eventInit: KeyboardEventInit = {
|
||||
key: cfg.key,
|
||||
code: cfg.code,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
};
|
||||
|
||||
const down = new KeyboardEvent('keydown', eventInit);
|
||||
const up = new KeyboardEvent('keyup', eventInit);
|
||||
|
||||
Object.defineProperty(down, 'keyCode', { get: () => cfg.keyCode });
|
||||
Object.defineProperty(down, 'which', { get: () => cfg.keyCode });
|
||||
Object.defineProperty(up, 'keyCode', { get: () => cfg.keyCode });
|
||||
Object.defineProperty(up, 'which', { get: () => cfg.keyCode });
|
||||
|
||||
document.activeElement?.dispatchEvent(down);
|
||||
window.dispatchEvent(down);
|
||||
document.dispatchEvent(down);
|
||||
|
||||
document.activeElement?.dispatchEvent(up);
|
||||
window.dispatchEvent(up);
|
||||
document.dispatchEvent(up);
|
||||
}
|
||||
|
||||
function RemoteButton({
|
||||
label,
|
||||
onClick,
|
||||
className = '',
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type='button'
|
||||
aria-label={label}
|
||||
title={label}
|
||||
onClick={onClick}
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
className={`flex cursor-pointer items-center justify-center rounded-2xl border border-white/10 bg-white/10 text-white shadow-lg shadow-black/30 outline-none transition hover:bg-white/20 active:scale-95 focus-visible:ring-4 focus-visible:ring-rose-500/70 ${className}`}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export default function TVVirtualRemote() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const lastFocusedRef = useRef<HTMLElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const onFocusIn = (event: FocusEvent) => {
|
||||
const target = event.target;
|
||||
if (target instanceof HTMLElement && !target.closest('[data-tv-remote]')) {
|
||||
lastFocusedRef.current = target;
|
||||
}
|
||||
};
|
||||
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'F1') {
|
||||
event.preventDefault();
|
||||
setOpen((value) => !value);
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.defaultPrevented) return;
|
||||
|
||||
if (event.key === 'ArrowUp' || event.key === 'ArrowDown' || event.key === 'ArrowLeft' || event.key === 'ArrowRight') {
|
||||
event.preventDefault();
|
||||
const direction = event.key.replace('Arrow', '').toLowerCase() as 'up' | 'down' | 'left' | 'right';
|
||||
moveSpatialFocus(direction, lastFocusedRef.current);
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === 'Enter') {
|
||||
const active = document.activeElement;
|
||||
if (active instanceof HTMLElement && !active.closest('input, textarea, select') && !active.closest('[data-tv-remote]')) {
|
||||
event.preventDefault();
|
||||
activateFocused();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
window.history.back();
|
||||
}
|
||||
|
||||
if (event.key === 'Home') {
|
||||
event.preventDefault();
|
||||
window.location.href = '/tv';
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('focusin', onFocusIn);
|
||||
window.addEventListener('keydown', onKeyDown, true);
|
||||
return () => {
|
||||
document.removeEventListener('focusin', onFocusIn);
|
||||
window.removeEventListener('keydown', onKeyDown, true);
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
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'>
|
||||
<div className='mb-4 flex items-center justify-between'>
|
||||
<div>
|
||||
<div className='text-xl font-black'>虚拟遥控器</div>
|
||||
<div className='text-sm text-slate-400'>F1 打开 / 关闭</div>
|
||||
</div>
|
||||
<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' />
|
||||
</RemoteButton>
|
||||
</div>
|
||||
|
||||
<div className='grid grid-cols-3 gap-3'>
|
||||
<RemoteButton label='返回' onClick={() => fireRemoteKey('back')} className='h-14'>
|
||||
<RotateCcw className='h-6 w-6' />
|
||||
</RemoteButton>
|
||||
<RemoteButton label='主页' onClick={() => fireRemoteKey('home')} className='h-14'>
|
||||
<Home className='h-6 w-6' />
|
||||
</RemoteButton>
|
||||
<RemoteButton label='菜单' onClick={() => fireRemoteKey('menu')} className='h-14'>
|
||||
<Menu className='h-6 w-6' />
|
||||
</RemoteButton>
|
||||
|
||||
<div />
|
||||
<RemoteButton label='上' onClick={() => fireRemoteKey('up')} className='h-16'>
|
||||
<ChevronUp className='h-9 w-9' />
|
||||
</RemoteButton>
|
||||
<div />
|
||||
|
||||
<RemoteButton label='左' onClick={() => fireRemoteKey('left')} className='h-16'>
|
||||
<ChevronLeft className='h-9 w-9' />
|
||||
</RemoteButton>
|
||||
<RemoteButton label='确认' onClick={() => fireRemoteKey('ok')} className='h-16 rounded-full bg-white text-black hover:bg-slate-200'>
|
||||
<CornerDownLeft className='h-8 w-8' />
|
||||
</RemoteButton>
|
||||
<RemoteButton label='右' onClick={() => fireRemoteKey('right')} className='h-16'>
|
||||
<ChevronRight className='h-9 w-9' />
|
||||
</RemoteButton>
|
||||
|
||||
<div />
|
||||
<RemoteButton label='下' onClick={() => fireRemoteKey('down')} className='h-16'>
|
||||
<ChevronDown className='h-9 w-9' />
|
||||
</RemoteButton>
|
||||
<div />
|
||||
</div>
|
||||
|
||||
<div className='mt-4 rounded-2xl bg-white/[0.06] p-3 text-center text-sm text-slate-300'>
|
||||
点击按钮会向当前页面发送方向键 / Enter / Esc
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
export interface TVItem {
|
||||
id: string;
|
||||
title: string;
|
||||
poster?: string;
|
||||
rate?: string;
|
||||
year?: string;
|
||||
type?: 'movie' | 'tv';
|
||||
href?: string;
|
||||
}
|
||||
|
||||
|
||||
export interface TVSection {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
href?: string;
|
||||
items: TVItem[];
|
||||
}
|
||||
Reference in New Issue
Block a user