tv模式初版
This commit is contained in:
@@ -0,0 +1,12 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { getQrLoginSession } from '@/lib/qr-login/store';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const { token } = await request.json();
|
||||
const session = getQrLoginSession(token);
|
||||
if (session) session.status = 'cancelled';
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { getAuthInfoFromCookie } from '@/lib/auth';
|
||||
import { getQrLoginSession } from '@/lib/qr-login/store';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const { token } = await request.json();
|
||||
const session = getQrLoginSession(token);
|
||||
if (!session || session.status === 'expired') return NextResponse.json({ error: '二维码已过期' }, { status: 410 });
|
||||
if (session.status === 'cancelled' || session.status === 'used') return NextResponse.json({ error: '二维码不可用' }, { status: 400 });
|
||||
|
||||
const authInfo = getAuthInfoFromCookie(request);
|
||||
const authCookie = request.cookies.get('auth')?.value;
|
||||
if (!authInfo || !authCookie) return NextResponse.json({ error: '请先在手机端登录后再确认' }, { status: 401 });
|
||||
|
||||
session.status = 'confirmed';
|
||||
session.authToken = authCookie;
|
||||
session.userAgent = request.headers.get('user-agent') || '';
|
||||
return NextResponse.json({ ok: true, status: 'confirmed' });
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { createQrLoginSession } from '@/lib/qr-login/store';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const session = createQrLoginSession();
|
||||
const origin = new URL(request.url).origin;
|
||||
const qrUrl = `${origin}/qr-login?token=${encodeURIComponent(session.token)}`;
|
||||
return NextResponse.json({ token: session.token, qrUrl, expiresAt: session.expiresAt, ttl: 120 });
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { getQrLoginSession } from '@/lib/qr-login/store';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const token = new URL(request.url).searchParams.get('token');
|
||||
const session = getQrLoginSession(token);
|
||||
if (!session) return NextResponse.json({ status: 'expired' });
|
||||
|
||||
if (session.status === 'confirmed' && session.authToken) {
|
||||
session.status = 'used';
|
||||
const response = NextResponse.json({ status: 'confirmed' });
|
||||
const expires = new Date();
|
||||
expires.setDate(expires.getDate() + 60);
|
||||
response.cookies.set('auth', session.authToken, {
|
||||
path: '/',
|
||||
expires,
|
||||
sameSite: 'lax',
|
||||
httpOnly: false,
|
||||
secure: false,
|
||||
});
|
||||
return response;
|
||||
}
|
||||
|
||||
return NextResponse.json({ status: session.status, expiresAt: session.expiresAt });
|
||||
}
|
||||
@@ -270,3 +270,58 @@ div[data-media-provider] video {
|
||||
.dark .reader-book-loader {
|
||||
color: #38bdf8;
|
||||
}
|
||||
|
||||
|
||||
/* TV 端遥控器焦点:深色选中 + 放大,而不是细边框 */
|
||||
.tv-focusable {
|
||||
position: relative;
|
||||
scroll-margin-top: 150px;
|
||||
scroll-margin-bottom: 96px;
|
||||
scroll-margin-left: 48px;
|
||||
scroll-margin-right: 48px;
|
||||
transform-origin: center;
|
||||
transition:
|
||||
transform 180ms ease,
|
||||
background-color 180ms ease,
|
||||
color 180ms ease,
|
||||
box-shadow 180ms ease,
|
||||
opacity 180ms ease;
|
||||
}
|
||||
|
||||
.tv-focusable:focus,
|
||||
.tv-focusable:focus-visible,
|
||||
.tv-focused {
|
||||
outline: none !important;
|
||||
transform: scale(1.055);
|
||||
background-color: rgba(15, 23, 42, 0.96) !important;
|
||||
color: #ffffff !important;
|
||||
box-shadow:
|
||||
0 18px 44px rgba(0, 0, 0, 0.68),
|
||||
0 0 26px rgba(225, 29, 72, 0.38) !important;
|
||||
z-index: 30;
|
||||
}
|
||||
|
||||
.tv-focusable:focus::after,
|
||||
.tv-focusable:focus-visible::after,
|
||||
.tv-focused::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: -3px;
|
||||
border-radius: inherit;
|
||||
background: linear-gradient(135deg, rgba(255,255,255,0.22), rgba(225,29,72,0.28));
|
||||
opacity: 0.45;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.tv-focusable:active {
|
||||
transform: scale(1.03);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.tv-focusable,
|
||||
.tv-focusable:focus,
|
||||
.tv-focusable:focus-visible,
|
||||
.tv-focused {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
'use client';
|
||||
|
||||
import { CheckCircle, Loader2, LogIn, XCircle } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { Suspense, useState } from 'react';
|
||||
|
||||
import { getAuthInfoFromBrowserCookie } from '@/lib/auth';
|
||||
|
||||
function QrLoginClient() {
|
||||
const searchParams = useSearchParams();
|
||||
const token = searchParams.get('token') || '';
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [message, setMessage] = useState('');
|
||||
const authed = Boolean(getAuthInfoFromBrowserCookie());
|
||||
|
||||
const confirm = async () => {
|
||||
setLoading(true);
|
||||
setMessage('');
|
||||
const res = await fetch('/api/auth/qr/confirm', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ token }),
|
||||
});
|
||||
const data = await res.json();
|
||||
setLoading(false);
|
||||
setMessage(res.ok ? '确认成功,请回到电视查看。' : data.error || '确认失败');
|
||||
};
|
||||
|
||||
const cancel = async () => {
|
||||
await fetch('/api/auth/qr/cancel', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ token }) });
|
||||
setMessage('已取消本次电视登录。');
|
||||
};
|
||||
|
||||
return (
|
||||
<main className='min-h-screen bg-black px-5 py-10 text-white'>
|
||||
<section className='mx-auto max-w-md rounded-[32px] border border-white/10 bg-slate-950 p-7 shadow-2xl shadow-black'>
|
||||
<h1 className='text-3xl font-black'>确认登录电视端</h1>
|
||||
<p className='mt-3 text-slate-300'>请确认电视屏幕上的二维码来自你正在使用的设备。</p>
|
||||
{!authed ? (
|
||||
<div className='mt-8 rounded-3xl bg-white/5 p-5'>
|
||||
<LogIn className='h-12 w-12 text-rose-400' />
|
||||
<p className='mt-4 text-lg font-bold'>当前手机未登录,请先登录后再确认电视登录。</p>
|
||||
<Link href={`/login?redirect=${encodeURIComponent(`/qr-login?token=${token}`)}`} className='mt-5 block rounded-2xl bg-rose-600 px-5 py-4 text-center text-lg font-black'>去登录</Link>
|
||||
</div>
|
||||
) : (
|
||||
<div className='mt-8 grid gap-3'>
|
||||
<button onClick={confirm} disabled={loading || !token} className='flex items-center justify-center gap-2 rounded-2xl bg-rose-600 px-5 py-4 text-lg font-black disabled:opacity-60'>
|
||||
{loading ? <Loader2 className='h-5 w-5 animate-spin' /> : <CheckCircle className='h-5 w-5' />} 确认登录
|
||||
</button>
|
||||
<button onClick={cancel} className='flex items-center justify-center gap-2 rounded-2xl bg-white/10 px-5 py-4 text-lg font-bold'>
|
||||
<XCircle className='h-5 w-5' /> 取消
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{message && <p className='mt-5 rounded-2xl bg-white/10 p-4 text-center text-lg font-bold'>{message}</p>}
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
export default function QrLoginPage() {
|
||||
return <Suspense fallback={null}><QrLoginClient /></Suspense>;
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
'use client';
|
||||
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import {
|
||||
Favorite,
|
||||
getAllFavorites,
|
||||
getAllPlayRecords,
|
||||
PlayRecord,
|
||||
} from '@/lib/db.client';
|
||||
|
||||
import TVLayout from '@/components/tv/TVLayout';
|
||||
import TVRow from '@/components/tv/TVRow';
|
||||
import { TVItem, TVSection } from '@/components/tv/types';
|
||||
|
||||
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) return [];
|
||||
const data = await res.json();
|
||||
return (data.list || []).map((item: TVItem) => ({ ...item, type }));
|
||||
}
|
||||
|
||||
function hrefFromKey(key: string, item: { title: string; year?: string; origin?: 'vod' | 'live' }) {
|
||||
const plus = key.indexOf('+');
|
||||
if (plus > 0) {
|
||||
const source = key.slice(0, plus);
|
||||
const id = key.slice(plus + 1);
|
||||
if (item.origin === 'live') {
|
||||
return `/tv/live/play?source=${encodeURIComponent(source.replace(/^live_/, ''))}&id=${encodeURIComponent(id.replace(/^live_/, ''))}`;
|
||||
}
|
||||
return `/tv/play?source=${encodeURIComponent(source)}&id=${encodeURIComponent(id)}&title=${encodeURIComponent(item.title)}`;
|
||||
}
|
||||
return `/tv/play?title=${encodeURIComponent(item.title)}${item.year ? `&year=${encodeURIComponent(item.year)}` : ''}`;
|
||||
}
|
||||
|
||||
function recordToItem([key, record]: [string, PlayRecord]): TVItem {
|
||||
return {
|
||||
id: key,
|
||||
title: record.title,
|
||||
poster: record.cover,
|
||||
year: record.year || `${record.index + 1}/${record.total_episodes || 1}`,
|
||||
rate: record.total_time ? `${Math.max(1, Math.round((record.play_time / record.total_time) * 100))}%` : '继续',
|
||||
href: hrefFromKey(key, record),
|
||||
};
|
||||
}
|
||||
|
||||
function favoriteToItem([key, favorite]: [string, Favorite]): TVItem {
|
||||
return {
|
||||
id: key,
|
||||
title: favorite.title,
|
||||
poster: favorite.cover,
|
||||
year: favorite.year,
|
||||
rate: '收藏',
|
||||
href: hrefFromKey(key, favorite),
|
||||
};
|
||||
}
|
||||
|
||||
export default function TVHomeClient() {
|
||||
const [sections, setSections] = useState<TVSection[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
async function load() {
|
||||
setLoading(true);
|
||||
const [records, favorites, hotMovies, hotSeries, anime, variety] = await Promise.all([
|
||||
getAllPlayRecords().catch(() => ({})),
|
||||
getAllFavorites().catch(() => ({})),
|
||||
loadDouban('movie', '热门', 'movie'),
|
||||
loadDouban('tv', '热门', 'tv'),
|
||||
loadDouban('tv', '动画', 'tv'),
|
||||
loadDouban('tv', '综艺', 'tv'),
|
||||
]);
|
||||
if (!alive) return;
|
||||
|
||||
const playItems = Object.entries(records)
|
||||
.sort((a, b) => (b[1].save_time || 0) - (a[1].save_time || 0))
|
||||
.slice(0, 20)
|
||||
.map(recordToItem);
|
||||
const favoriteItems = Object.entries(favorites)
|
||||
.sort((a, b) => (b[1].save_time || 0) - (a[1].save_time || 0))
|
||||
.slice(0, 20)
|
||||
.map(favoriteToItem);
|
||||
|
||||
const next: TVSection[] = [
|
||||
{ title: '继续观看', subtitle: '最近 20 条播放记录', items: playItems },
|
||||
{ title: '我的收藏', subtitle: '最近收藏的内容', items: favoriteItems },
|
||||
{ title: '热门电影', subtitle: '今晚就看这些高热影片', href: '/tv/movie', items: hotMovies },
|
||||
{ title: '热门剧集', subtitle: '连续播放更适合电视', href: '/tv/series', items: hotSeries },
|
||||
{ title: '动漫推荐', subtitle: '新番与经典动画', href: '/tv/anime', items: anime },
|
||||
{ title: '综艺推荐', subtitle: '轻松下饭大屏看', href: '/tv/variety', items: variety },
|
||||
].filter((section) => section.items.length > 0);
|
||||
|
||||
setSections(next);
|
||||
setLoading(false);
|
||||
}
|
||||
load();
|
||||
return () => { alive = false; };
|
||||
}, []);
|
||||
|
||||
const empty = useMemo(() => !loading && sections.length === 0, [loading, sections.length]);
|
||||
|
||||
return (
|
||||
<TVLayout>
|
||||
{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>
|
||||
) : empty ? (
|
||||
<div className='rounded-[36px] border border-white/10 bg-white/[0.04] p-10 text-2xl text-slate-300'>暂无首页内容</div>
|
||||
) : (
|
||||
sections.map((section) => <TVRow key={section.title} section={section} />)
|
||||
)}
|
||||
</TVLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
'use client';
|
||||
|
||||
import { CalendarDays, Loader2 } from 'lucide-react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import {
|
||||
BangumiCalendarData,
|
||||
GetBangumiCalendarData,
|
||||
} from '@/lib/bangumi.client';
|
||||
|
||||
import TVCard from '@/components/tv/TVCard';
|
||||
import TVLayout from '@/components/tv/TVLayout';
|
||||
import TVRow from '@/components/tv/TVRow';
|
||||
import { TVItem, TVSection } from '@/components/tv/types';
|
||||
|
||||
const weekdayMap: Record<string, string> = {
|
||||
Mon: '周一',
|
||||
Tue: '周二',
|
||||
Wed: '周三',
|
||||
Thu: '周四',
|
||||
Fri: '周五',
|
||||
Sat: '周六',
|
||||
Sun: '周日',
|
||||
};
|
||||
|
||||
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) return [];
|
||||
const data = await res.json();
|
||||
return (data.list || []).map((item: TVItem) => ({ ...item, type }));
|
||||
}
|
||||
|
||||
function bangumiToItem(anime: BangumiCalendarData['items'][number]): TVItem {
|
||||
const title = anime.name_cn || anime.name;
|
||||
return {
|
||||
id: String(anime.id),
|
||||
title,
|
||||
poster:
|
||||
anime.images?.large ||
|
||||
anime.images?.common ||
|
||||
anime.images?.medium ||
|
||||
anime.images?.small ||
|
||||
anime.images?.grid ||
|
||||
'',
|
||||
rate: anime.rating?.score ? anime.rating.score.toFixed(1) : '新番',
|
||||
year: anime.air_date?.split('-')?.[0] || '更新中',
|
||||
type: 'tv',
|
||||
href: `/tv/play?title=${encodeURIComponent(title)}&stype=tv`,
|
||||
};
|
||||
}
|
||||
|
||||
export default function TVAnimePage() {
|
||||
const [calendar, setCalendar] = useState<BangumiCalendarData[]>([]);
|
||||
const [rows, setRows] = useState<TVSection[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [activeDay, setActiveDay] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
const weekdays = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
||||
setActiveDay(weekdays[new Date().getDay()]);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
async function load() {
|
||||
setLoading(true);
|
||||
const [calendarData, hotAnime, jpAnime, cnAnime, animeMovies] = await Promise.all([
|
||||
GetBangumiCalendarData().catch(() => []),
|
||||
loadDouban('tv', '动画', 'tv'),
|
||||
loadDouban('tv', '日本动画', 'tv'),
|
||||
loadDouban('tv', '国产动画', 'tv'),
|
||||
loadDouban('movie', '动画', 'movie'),
|
||||
]);
|
||||
if (!alive) return;
|
||||
setCalendar(calendarData);
|
||||
setRows([
|
||||
{ title: '热门动漫', subtitle: '热门动画与新番推荐', items: hotAnime },
|
||||
{ title: '日本动画', subtitle: '番剧、经典与口碑动画', items: jpAnime },
|
||||
{ title: '国产动画', subtitle: '国创动画专区', items: cnAnime },
|
||||
{ title: '动画电影', subtitle: '适合大屏观看的剧场版', items: animeMovies },
|
||||
].filter((section) => section.items.length > 0));
|
||||
setLoading(false);
|
||||
}
|
||||
load();
|
||||
return () => { alive = false; };
|
||||
}, []);
|
||||
|
||||
const activeCalendar = useMemo(() => {
|
||||
return calendar.find((item) => item.weekday.en === activeDay);
|
||||
}, [calendar, activeDay]);
|
||||
|
||||
const activeItems = useMemo(() => {
|
||||
return (activeCalendar?.items || []).filter((item) => item.images).map(bangumiToItem);
|
||||
}, [activeCalendar]);
|
||||
|
||||
return (
|
||||
<TVLayout>
|
||||
<section className='rounded-[42px] border border-white/10 bg-slate-950/70 p-8 shadow-2xl shadow-black/60'>
|
||||
<div className='flex items-center gap-4'>
|
||||
<CalendarDays className='h-14 w-14 text-rose-500' />
|
||||
<div>
|
||||
<h1 className='text-6xl font-black'>动漫更新时间表</h1>
|
||||
<p className='mt-2 text-2xl text-slate-300'>按周查看新番放送,遥控器左右选择日期。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='mt-8 flex justify-center gap-3 overflow-x-auto px-4 py-4 [scrollbar-width:none]'>
|
||||
{calendar.map((day) => (
|
||||
<button
|
||||
key={day.weekday.en}
|
||||
type='button'
|
||||
onClick={() => setActiveDay(day.weekday.en)}
|
||||
className={`tv-focusable cursor-pointer rounded-2xl px-7 py-4 text-2xl font-black outline-none transition ${
|
||||
activeDay === day.weekday.en
|
||||
? 'bg-rose-600 text-white'
|
||||
: 'bg-white/8 text-slate-200 hover:bg-white/12'
|
||||
}`}
|
||||
>
|
||||
{weekdayMap[day.weekday.en] || day.weekday.en}
|
||||
<span className='ml-2 text-lg text-slate-300'>{day.items?.length || 0}</span>
|
||||
</button>
|
||||
))}
|
||||
</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>
|
||||
) : (
|
||||
<>
|
||||
<section className='mt-10'>
|
||||
<div className='mb-5 flex items-end justify-between'>
|
||||
<div>
|
||||
<h2 className='text-4xl font-black tracking-tight text-white'>
|
||||
{weekdayMap[activeDay] || activeDay} 更新
|
||||
</h2>
|
||||
<p className='mt-2 text-xl text-slate-400'>当天放送的新番列表</p>
|
||||
</div>
|
||||
</div>
|
||||
{activeItems.length > 0 ? (
|
||||
<div className='flex gap-5 overflow-x-auto px-5 py-6 [scrollbar-width:none]'>
|
||||
{activeItems.map((item) => <TVCard key={item.id} item={item} />)}
|
||||
</div>
|
||||
) : (
|
||||
<div className='rounded-3xl border border-white/10 bg-white/[0.04] p-8 text-2xl text-slate-300'>暂无更新时间表数据</div>
|
||||
)}
|
||||
</section>
|
||||
{rows.map((section) => <TVRow key={section.title} section={section} />)}
|
||||
</>
|
||||
)}
|
||||
</TVLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
export const metadata = {
|
||||
title: 'TV - MoonTV Plus',
|
||||
};
|
||||
|
||||
export default function Layout({ children }: { children: ReactNode }) {
|
||||
return children;
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
'use client';
|
||||
|
||||
import { Loader2, Radio } from 'lucide-react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import TVLayout from '@/components/tv/TVLayout';
|
||||
|
||||
type LiveSource = { key: string; name: string };
|
||||
type LiveChannel = { id: string; name: string; group?: string; logo?: string };
|
||||
|
||||
export default function TVLivePage() {
|
||||
const router = useRouter();
|
||||
const [sources, setSources] = useState<LiveSource[]>([]);
|
||||
const [source, setSource] = useState<string>('');
|
||||
const [channels, setChannels] = useState<LiveChannel[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/live/sources')
|
||||
.then((r) => r.json())
|
||||
.then((data) => {
|
||||
const list = data.data || [];
|
||||
setSources(list);
|
||||
if (list[0]?.key) setSource(list[0].key);
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!source) return;
|
||||
setLoading(true);
|
||||
fetch(`/api/live/channels?source=${encodeURIComponent(source)}`)
|
||||
.then((r) => r.json())
|
||||
.then((data) => setChannels(data.data || []))
|
||||
.finally(() => setLoading(false));
|
||||
}, [source]);
|
||||
|
||||
const groups = useMemo(() => Array.from(new Set(channels.map((c) => c.group || '其他'))).slice(0, 12), [channels]);
|
||||
|
||||
return (
|
||||
<TVLayout>
|
||||
<section className='rounded-[42px] border border-white/10 bg-slate-950/70 p-10 shadow-2xl shadow-black/60'>
|
||||
<div className='flex items-center gap-4'>
|
||||
<Radio className='h-14 w-14 text-rose-500' />
|
||||
<div>
|
||||
<h1 className='text-6xl font-black'>直播</h1>
|
||||
<p className='mt-2 text-2xl text-slate-300'>选择频道后进入全屏直播播放页,频道列表作为播放层弹出。</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className='mt-8 flex gap-4 overflow-x-auto px-4 py-4 [scrollbar-width:none]'>
|
||||
{sources.map((item) => (
|
||||
<button key={item.key} onClick={() => setSource(item.key)} className={`cursor-pointer rounded-2xl px-6 py-4 text-2xl font-bold outline-none transition tv-focusable ${source === item.key ? 'bg-rose-600 text-white' : 'bg-white/8 text-slate-200 hover:bg-white/12'}`}>{item.name}</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{loading ? <div className='mt-16 flex justify-center gap-4 text-2xl text-slate-300'><Loader2 className='h-8 w-8 animate-spin' />正在加载频道...</div> : (
|
||||
<div className='mt-10 grid grid-cols-[280px_1fr] gap-6'>
|
||||
<aside className='rounded-[32px] border border-white/10 bg-white/[0.04] p-4'>
|
||||
{groups.map((group) => <div key={group} className='rounded-2xl px-5 py-4 text-2xl font-bold text-slate-200'>{group}</div>)}
|
||||
</aside>
|
||||
<section className='grid grid-cols-2 gap-4 lg:grid-cols-4'>
|
||||
{channels.slice(0, 80).map((channel) => (
|
||||
<button key={channel.id} onClick={() => router.push(`/tv/live/play?source=${encodeURIComponent(source)}&id=${encodeURIComponent(channel.id)}`)} className='flex min-h-28 cursor-pointer items-center gap-4 rounded-3xl border border-white/10 bg-white/[0.06] p-5 text-left outline-none transition hover:bg-white/12 tv-focusable'>
|
||||
{channel.logo ? <img src={channel.logo} alt='' className='h-14 w-14 rounded-xl object-contain' /> : <Radio className='h-12 w-12 text-rose-400' />}
|
||||
<div><div className='line-clamp-1 text-2xl font-black'>{channel.name}</div><div className='mt-1 text-lg text-slate-400'>{channel.group || '直播频道'}</div></div>
|
||||
</button>
|
||||
))}
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
</TVLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
'use client';
|
||||
|
||||
import { ArrowLeft, ExternalLink, Radio } from 'lucide-react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { Suspense, useMemo, useState } from 'react';
|
||||
|
||||
import TVVirtualRemote from '@/components/tv/TVVirtualRemote';
|
||||
|
||||
function TVLivePlayClient() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const [showPanel, setShowPanel] = useState(true);
|
||||
const originalUrl = useMemo(() => `/live?${new URLSearchParams(searchParams.toString()).toString()}`, [searchParams]);
|
||||
|
||||
return (
|
||||
<main className='fixed inset-0 overflow-hidden bg-black text-white' onMouseMove={() => setShowPanel(true)}>
|
||||
<iframe src={originalUrl} title='TV 直播播放器' className='h-full w-full border-0 bg-black' allow='autoplay; fullscreen; picture-in-picture' allowFullScreen />
|
||||
<div className={`absolute left-6 right-6 top-6 flex items-center justify-between transition-opacity duration-300 ${showPanel ? 'opacity-100' : 'opacity-0'}`}>
|
||||
<button onClick={() => router.back()} className='flex cursor-pointer items-center gap-3 rounded-2xl bg-black/70 px-5 py-4 text-2xl font-black outline-none backdrop-blur transition hover:bg-white/15 tv-focusable'>
|
||||
<ArrowLeft className='h-7 w-7' /> 返回频道
|
||||
</button>
|
||||
<div className='flex items-center gap-3 rounded-2xl bg-black/70 px-5 py-4 text-xl font-bold backdrop-blur'><Radio className='h-6 w-6 text-rose-400' /> TV 全屏直播页</div>
|
||||
</div>
|
||||
<div className={`absolute bottom-8 left-1/2 flex -translate-x-1/2 gap-4 rounded-3xl bg-black/75 p-4 backdrop-blur transition-opacity duration-300 ${showPanel ? 'opacity-100' : 'opacity-0'}`}>
|
||||
<button onClick={() => setShowPanel(false)} className='cursor-pointer rounded-2xl bg-white/10 px-6 py-4 text-xl font-bold outline-none hover:bg-white/20 tv-focusable'>隐藏浮层</button>
|
||||
<a href={originalUrl} className='flex cursor-pointer items-center gap-2 rounded-2xl bg-rose-600 px-6 py-4 text-xl font-black outline-none hover:bg-rose-500 tv-focusable'><ExternalLink className='h-6 w-6' /> 原直播页</a>
|
||||
</div>
|
||||
<TVVirtualRemote />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
export default function TVLivePlayPage() {
|
||||
return <Suspense fallback={null}><TVLivePlayClient /></Suspense>;
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
'use client';
|
||||
|
||||
import { Loader2, RefreshCw, Smartphone } from 'lucide-react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import TVLayout from '@/components/tv/TVLayout';
|
||||
|
||||
type QrState = { token: string; qrUrl: string; expiresAt: number; ttl: number };
|
||||
|
||||
export default function TVLoginPage() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const redirect = searchParams.get('redirect') || '/tv';
|
||||
const [qr, setQr] = useState<QrState | null>(null);
|
||||
const [status, setStatus] = useState('正在生成二维码...');
|
||||
const [left, setLeft] = useState(0);
|
||||
|
||||
const create = useCallback(async () => {
|
||||
setStatus('正在生成二维码...');
|
||||
const res = await fetch('/api/auth/qr/create', { method: 'POST' });
|
||||
const data = await res.json();
|
||||
setQr(data);
|
||||
setLeft(Math.max(0, Math.ceil((data.expiresAt - Date.now()) / 1000)));
|
||||
setStatus('请使用手机扫码登录');
|
||||
}, []);
|
||||
|
||||
useEffect(() => { create(); }, [create]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!qr) return;
|
||||
const timer = window.setInterval(() => setLeft(Math.max(0, Math.ceil((qr.expiresAt - Date.now()) / 1000))), 1000);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [qr]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!qr) return;
|
||||
const timer = window.setInterval(async () => {
|
||||
const res = await fetch(`/api/auth/qr/status?token=${encodeURIComponent(qr.token)}`, { cache: 'no-store' });
|
||||
const data = await res.json();
|
||||
if (data.status === 'scanned') setStatus('已扫码,请在手机上确认');
|
||||
if (data.status === 'confirmed') {
|
||||
setStatus('登录成功,正在进入电视端');
|
||||
window.clearInterval(timer);
|
||||
router.replace(redirect);
|
||||
}
|
||||
if (data.status === 'expired') setStatus('二维码已过期,请刷新');
|
||||
if (data.status === 'cancelled') setStatus('已取消,请刷新二维码');
|
||||
}, 2000);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [qr, redirect, router]);
|
||||
|
||||
const qrImg = qr ? `https://api.qrserver.com/v1/create-qr-code/?size=360x360&margin=16&data=${encodeURIComponent(qr.qrUrl)}` : '';
|
||||
|
||||
return (
|
||||
<TVLayout>
|
||||
<section className='mx-auto grid max-w-6xl grid-cols-[1fr_430px] gap-10 rounded-[42px] border border-white/10 bg-slate-950/75 p-12 shadow-2xl shadow-black/60'>
|
||||
<div className='flex flex-col justify-center'>
|
||||
<div className='inline-flex w-fit items-center gap-3 rounded-full bg-rose-600 px-5 py-2 text-xl font-bold text-white'><Smartphone className='h-6 w-6' /> 手机确认 · 电视自动登录</div>
|
||||
<h1 className='mt-7 text-7xl font-black tracking-tight'>扫码登录</h1>
|
||||
<p className='mt-6 max-w-2xl text-3xl leading-relaxed text-slate-300'>用已登录的手机浏览器扫描右侧二维码,在手机上确认后,电视端会自动进入。</p>
|
||||
<p className='mt-8 text-2xl font-bold text-rose-300'>{status}</p>
|
||||
<button onClick={create} className='mt-10 flex w-fit cursor-pointer items-center gap-3 rounded-3xl bg-white px-8 py-5 text-2xl font-black text-black outline-none transition hover:bg-slate-200 focus-visible:ring-4 focus-visible:ring-rose-500/70'>
|
||||
<RefreshCw className='h-7 w-7' /> 刷新二维码
|
||||
</button>
|
||||
</div>
|
||||
<div className='rounded-[36px] border border-white/10 bg-white p-7 text-center text-black shadow-2xl shadow-black/60'>
|
||||
{qrImg ? <img src={qrImg} alt='扫码登录二维码' className='mx-auto h-[360px] w-[360px]' /> : <div className='flex h-[360px] items-center justify-center'><Loader2 className='h-12 w-12 animate-spin' /></div>}
|
||||
<div className='mt-5 text-2xl font-black'>剩余 {left} 秒</div>
|
||||
<div className='mt-2 break-all text-sm text-slate-500'>{qr?.qrUrl}</div>
|
||||
</div>
|
||||
</section>
|
||||
</TVLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import TVBrowsePage from '@/components/tv/TVBrowsePage';
|
||||
|
||||
export default function TVMoviePage() {
|
||||
return <TVBrowsePage title='电影' subtitle='影院感海报墙,遥控器快速选片。' sections={[
|
||||
{ title: '热门电影', kind: 'movie', tag: '热门', type: 'movie' },
|
||||
{ title: '高分电影', kind: 'movie', tag: '高分', type: 'movie' },
|
||||
{ title: '动作电影', kind: 'movie', tag: '动作', type: 'movie' },
|
||||
{ title: '科幻电影', kind: 'movie', tag: '科幻', type: 'movie' },
|
||||
]} />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import TVHomeClient from './TVHomeClient';
|
||||
|
||||
export default function TVHomePage() {
|
||||
return <TVHomeClient />;
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
'use client';
|
||||
|
||||
import { ArrowLeft, ExternalLink, Layers, Maximize2 } from 'lucide-react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { Suspense, useMemo, useState } from 'react';
|
||||
|
||||
import TVVirtualRemote from '@/components/tv/TVVirtualRemote';
|
||||
|
||||
function TVPlayClient() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const [showControls, setShowControls] = useState(true);
|
||||
|
||||
const originalUrl = useMemo(() => {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
return `/play?${params.toString()}`;
|
||||
}, [searchParams]);
|
||||
|
||||
return (
|
||||
<main className='fixed inset-0 overflow-hidden bg-black text-white' onMouseMove={() => setShowControls(true)}>
|
||||
<iframe src={originalUrl} title='TV 播放器' className='h-full w-full border-0 bg-black' allow='autoplay; fullscreen; picture-in-picture' allowFullScreen />
|
||||
<div className={`pointer-events-none absolute inset-0 transition-opacity duration-300 ${showControls ? 'opacity-100' : 'opacity-0'}`}>
|
||||
<div className='absolute inset-x-0 top-0 h-40 bg-gradient-to-b from-black/85 to-transparent' />
|
||||
<div className='absolute inset-x-0 bottom-0 h-44 bg-gradient-to-t from-black/85 to-transparent' />
|
||||
</div>
|
||||
<div className={`absolute left-6 right-6 top-6 flex items-center justify-between transition-opacity duration-300 ${showControls ? 'opacity-100' : 'opacity-0'}`}>
|
||||
<button onClick={() => router.back()} className='flex cursor-pointer items-center gap-3 rounded-2xl bg-black/70 px-5 py-4 text-2xl font-black outline-none backdrop-blur transition hover:bg-white/15 tv-focusable'>
|
||||
<ArrowLeft className='h-7 w-7' /> 返回
|
||||
</button>
|
||||
<div className='flex items-center gap-3 rounded-2xl bg-black/70 px-5 py-4 text-xl font-bold text-slate-200 backdrop-blur'>
|
||||
<Maximize2 className='h-6 w-6 text-rose-400' /> TV 全屏播放页
|
||||
</div>
|
||||
</div>
|
||||
<div className={`absolute bottom-8 left-1/2 flex -translate-x-1/2 items-center gap-4 rounded-3xl bg-black/75 p-4 backdrop-blur transition-opacity duration-300 ${showControls ? 'opacity-100' : 'opacity-0'}`}>
|
||||
<button onClick={() => setShowControls(false)} className='flex cursor-pointer items-center gap-2 rounded-2xl bg-white/10 px-6 py-4 text-xl font-bold outline-none hover:bg-white/20 tv-focusable'>
|
||||
<Layers className='h-6 w-6' /> 隐藏浮层
|
||||
</button>
|
||||
<a href={originalUrl} className='flex cursor-pointer items-center gap-2 rounded-2xl bg-rose-600 px-6 py-4 text-xl font-black outline-none hover:bg-rose-500 tv-focusable'>
|
||||
<ExternalLink className='h-6 w-6' /> 原播放页
|
||||
</a>
|
||||
</div>
|
||||
<TVVirtualRemote />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
export default function TVPlayPage() {
|
||||
return <Suspense fallback={null}><TVPlayClient /></Suspense>;
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
'use client';
|
||||
|
||||
import { Folder, HardDrive, Loader2, Lock, PlayCircle, Server } from 'lucide-react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { getAuthInfoFromBrowserCookie } from '@/lib/auth';
|
||||
import { base58Encode } from '@/lib/utils';
|
||||
|
||||
import TVLayout from '@/components/tv/TVLayout';
|
||||
import TVCard from '@/components/tv/TVCard';
|
||||
import { TVItem } from '@/components/tv/types';
|
||||
|
||||
type SourceType = 'openlist' | 'emby' | 'xiaoya';
|
||||
type Video = { id: string; title: string; poster?: string; year?: string; rating?: number; voteAverage?: number };
|
||||
type EmbySource = { key: string; name: string };
|
||||
type XiaoyaItem = { name: string; path: string };
|
||||
|
||||
export default function TVPrivatePage() {
|
||||
const router = useRouter();
|
||||
const runtimeConfig = useMemo(() => (typeof window !== 'undefined' ? (window as any).RUNTIME_CONFIG || {} : {}), []);
|
||||
const enabledSources: SourceType[] = useMemo(() => [
|
||||
runtimeConfig.OPENLIST_ENABLED ? 'openlist' : null,
|
||||
runtimeConfig.EMBY_ENABLED ? 'emby' : null,
|
||||
runtimeConfig.XIAOYA_ENABLED ? 'xiaoya' : null,
|
||||
].filter(Boolean) as SourceType[], [runtimeConfig]);
|
||||
|
||||
const [ready, setReady] = useState(false);
|
||||
const [authed, setAuthed] = useState(false);
|
||||
const [source, setSource] = useState<SourceType>('openlist');
|
||||
const [embyKey, setEmbyKey] = useState('');
|
||||
const [embySources, setEmbySources] = useState<EmbySource[]>([]);
|
||||
const [videos, setVideos] = useState<TVItem[]>([]);
|
||||
const [folders, setFolders] = useState<XiaoyaItem[]>([]);
|
||||
const [files, setFiles] = useState<XiaoyaItem[]>([]);
|
||||
const [xiaoyaPath, setXiaoyaPath] = useState('/');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
const ok = Boolean(getAuthInfoFromBrowserCookie());
|
||||
setAuthed(ok);
|
||||
setReady(true);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (ready && !authed) router.replace('/tv/login?redirect=/tv/private');
|
||||
}, [ready, authed, router]);
|
||||
|
||||
useEffect(() => {
|
||||
if (enabledSources.length > 0 && !enabledSources.includes(source)) {
|
||||
setSource(enabledSources[0]);
|
||||
}
|
||||
}, [enabledSources, source]);
|
||||
|
||||
useEffect(() => {
|
||||
if (source !== 'emby') return;
|
||||
fetch('/api/emby/sources').then((r) => r.json()).then((data) => {
|
||||
const list = data.sources || [];
|
||||
setEmbySources(list);
|
||||
if (!embyKey && list[0]?.key) setEmbyKey(list[0].key);
|
||||
}).catch(() => undefined);
|
||||
}, [source, embyKey]);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!authed || enabledSources.length === 0) return;
|
||||
if (source === 'emby' && !embyKey) return;
|
||||
setLoading(true);
|
||||
setError('');
|
||||
setVideos([]);
|
||||
setFolders([]);
|
||||
setFiles([]);
|
||||
try {
|
||||
const endpoint = source === 'openlist'
|
||||
? '/api/openlist/list?page=1&pageSize=40'
|
||||
: source === 'xiaoya'
|
||||
? `/api/xiaoya/browse?path=${encodeURIComponent(xiaoyaPath)}`
|
||||
: `/api/emby/list?page=1&pageSize=40&embyKey=${encodeURIComponent(embyKey)}&sortBy=DateCreated&sortOrder=Descending`;
|
||||
const res = await fetch(endpoint);
|
||||
if (!res.ok) throw new Error('获取私人影库失败');
|
||||
const data = await res.json();
|
||||
if (data.error) throw new Error(data.error);
|
||||
if (source === 'xiaoya') {
|
||||
setFolders(data.folders || []);
|
||||
setFiles(data.files || []);
|
||||
} else {
|
||||
const list: Video[] = data.list || [];
|
||||
setVideos(list.map((item) => ({
|
||||
id: item.id,
|
||||
title: item.title,
|
||||
poster: item.poster,
|
||||
year: item.year,
|
||||
rate: item.rating || item.voteAverage ? String(item.rating || item.voteAverage) : '私人',
|
||||
href: `/tv/play?source=${encodeURIComponent(source === 'emby' && embySources.length > 1 ? `emby:${embyKey}` : source)}&id=${encodeURIComponent(item.id)}&title=${encodeURIComponent(item.title)}`,
|
||||
})));
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '获取私人影库失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [authed, enabledSources.length, source, embyKey, embySources.length, xiaoyaPath]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const sourceLabel = { openlist: 'OpenList', emby: 'Emby', xiaoya: '小雅' };
|
||||
|
||||
if (!ready || !authed) {
|
||||
return <TVLayout><section className='mx-auto max-w-5xl rounded-[42px] border border-white/10 bg-slate-950/70 p-12 text-center shadow-2xl shadow-black/60'><Lock className='mx-auto h-20 w-20 text-rose-500' /><h1 className='mt-6 text-6xl font-black'>需要扫码登录</h1><p className='mt-5 text-2xl text-slate-300'>正在跳转到电视扫码登录页...</p></section></TVLayout>;
|
||||
}
|
||||
|
||||
return (
|
||||
<TVLayout>
|
||||
<section className='rounded-[42px] border border-white/10 bg-slate-950/70 p-8 shadow-2xl shadow-black/60'>
|
||||
<div className='flex items-center justify-between gap-6'>
|
||||
<div className='flex items-center gap-4'><HardDrive className='h-14 w-14 text-rose-500' /><div><h1 className='text-6xl font-black'>私人影库</h1><p className='mt-2 text-2xl text-slate-300'>已接入真实 OpenList / Emby / 小雅数据。</p></div></div>
|
||||
<div className='flex gap-3'>
|
||||
{enabledSources.map((item) => <button key={item} onClick={() => setSource(item)} className={`cursor-pointer rounded-2xl px-6 py-4 text-2xl font-bold outline-none transition tv-focusable ${source === item ? 'bg-rose-600 text-white' : 'bg-white/8 text-slate-200 hover:bg-white/12'}`}>{sourceLabel[item]}</button>)}
|
||||
</div>
|
||||
</div>
|
||||
{source === 'emby' && embySources.length > 1 && <div className='mt-6 flex gap-3 overflow-x-auto px-3 py-3 [scrollbar-width:none]'>{embySources.map((item) => <button key={item.key} onClick={() => setEmbyKey(item.key)} className={`cursor-pointer rounded-2xl px-5 py-3 text-xl font-bold ${embyKey === item.key ? 'bg-white text-black' : 'bg-white/10 text-white'}`}><Server className='mr-2 inline h-5 w-5' />{item.name}</button>)}</div>}
|
||||
</section>
|
||||
|
||||
{loading && <div className='mt-16 flex justify-center gap-4 text-2xl text-slate-300'><Loader2 className='h-8 w-8 animate-spin' />正在加载私人影库...</div>}
|
||||
{error && <div className='mt-8 rounded-3xl border border-red-500/40 bg-red-950/40 p-6 text-2xl text-red-100'>{error}</div>}
|
||||
{!loading && enabledSources.length === 0 && <div className='mt-8 rounded-3xl border border-white/10 bg-white/[0.04] p-8 text-2xl text-slate-300'>未启用私人影库源。</div>}
|
||||
|
||||
{!loading && source !== 'xiaoya' && videos.length > 0 && <section className='mt-10 grid grid-cols-2 gap-5 md:grid-cols-4 lg:grid-cols-6'>{videos.map((item) => <TVCard key={item.id} item={item} />)}</section>}
|
||||
|
||||
{!loading && source === 'xiaoya' && <section className='mt-10 space-y-8'>
|
||||
<div className='rounded-3xl bg-white/[0.04] p-5 text-xl text-slate-300'>当前位置:{xiaoyaPath}</div>
|
||||
{xiaoyaPath !== '/' && <button onClick={() => setXiaoyaPath('/' + xiaoyaPath.split('/').filter(Boolean).slice(0, -1).join('/'))} className='cursor-pointer rounded-2xl bg-white/10 px-6 py-4 text-2xl font-bold outline-none tv-focusable'>返回上级</button>}
|
||||
{folders.length > 0 && <div><h2 className='mb-5 text-4xl font-black'>文件夹</h2><div className='grid grid-cols-2 gap-4 md:grid-cols-4'>{folders.map((folder) => <button key={folder.path} onClick={() => setXiaoyaPath(folder.path)} className='flex min-h-24 cursor-pointer items-center gap-3 rounded-3xl border border-white/10 bg-white/[0.06] p-5 text-left text-2xl font-bold outline-none tv-focusable'><Folder className='h-9 w-9 text-rose-400' />{folder.name}</button>)}</div></div>}
|
||||
{files.length > 0 && <div><h2 className='mb-5 text-4xl font-black'>视频文件</h2><div className='grid gap-4'>{files.map((file) => {
|
||||
const pathParts = xiaoyaPath.split('/').filter(Boolean);
|
||||
const folderName = pathParts[pathParts.length - 1] || '';
|
||||
const title = folderName.replace(/\s*\(\d{4}\)\s*\{tmdb-\d+\}$/i, '').trim() || file.name;
|
||||
const encodedDirPath = base58Encode(xiaoyaPath);
|
||||
return <button key={file.path} onClick={() => router.push(`/tv/play?source=xiaoya&id=${encodeURIComponent(encodedDirPath)}&fileName=${encodeURIComponent(file.name)}&title=${encodeURIComponent(title)}`)} className='flex cursor-pointer items-center gap-4 rounded-3xl border border-white/10 bg-white/[0.06] p-5 text-left text-2xl font-bold outline-none tv-focusable'><PlayCircle className='h-9 w-9 text-rose-400' />{file.name}</button>;
|
||||
})}</div></div>}
|
||||
</section>}
|
||||
</TVLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
'use client';
|
||||
|
||||
import { Search } from 'lucide-react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { FormEvent, useEffect, useState } from 'react';
|
||||
|
||||
import { addSearchHistory, getSearchHistory } from '@/lib/db.client';
|
||||
|
||||
import TVLayout from '@/components/tv/TVLayout';
|
||||
|
||||
const hot = ['庆余年', '流浪地球', '繁花', '甄嬛传', '鬼灭之刃', '歌手', '三体', '权力的游戏'];
|
||||
|
||||
export default function TVSearchPage() {
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [history, setHistory] = useState<string[]>([]);
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
getSearchHistory().then(setHistory).catch(() => setHistory([]));
|
||||
}, []);
|
||||
|
||||
const submit = (event?: FormEvent) => {
|
||||
event?.preventDefault();
|
||||
const q = keyword.trim();
|
||||
if (q) {
|
||||
addSearchHistory(q).catch(() => undefined);
|
||||
router.push(`/tv/play?title=${encodeURIComponent(q)}`);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<TVLayout>
|
||||
<section className='mx-auto max-w-6xl rounded-[42px] border border-white/10 bg-slate-950/70 p-10 shadow-2xl shadow-black/60'>
|
||||
<h1 className='text-6xl font-black'>搜索</h1>
|
||||
<p className='mt-4 text-2xl text-slate-300'>输入片名后直接进入 TV 全屏播放页,后续可接入屏幕键盘。</p>
|
||||
<form onSubmit={submit} className='mt-10 flex gap-4'>
|
||||
<label className='sr-only' htmlFor='tv-search'>搜索片名</label>
|
||||
<input
|
||||
id='tv-search'
|
||||
autoFocus
|
||||
value={keyword}
|
||||
onChange={(e) => setKeyword(e.target.value)}
|
||||
placeholder='输入电影、剧集、动漫、综艺名称'
|
||||
className='h-20 flex-1 rounded-3xl border border-white/10 bg-white/10 px-8 text-3xl text-white outline-none placeholder:text-slate-500 focus:border-rose-500 tv-focusable'
|
||||
/>
|
||||
<button type='submit' className='flex h-20 cursor-pointer items-center gap-3 rounded-3xl bg-rose-600 px-10 text-3xl font-black text-white outline-none transition hover:bg-rose-500 tv-focusable'>
|
||||
<Search className='h-9 w-9' /> 搜索
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
{history.length > 0 && (
|
||||
<section className='mx-auto mt-12 max-w-6xl'>
|
||||
<h2 className='text-4xl font-black'>搜索历史</h2>
|
||||
<div className='mt-6 grid grid-cols-2 gap-4 md:grid-cols-4'>
|
||||
{history.slice(0, 20).map((item) => (
|
||||
<button key={item} onClick={() => router.push(`/tv/play?title=${encodeURIComponent(item)}`)} className='cursor-pointer rounded-3xl border border-white/10 bg-white/[0.06] px-6 py-5 text-2xl font-bold text-white outline-none transition hover:bg-white/12 tv-focusable'>
|
||||
{item}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
<section className='mx-auto mt-12 max-w-6xl'>
|
||||
<h2 className='text-4xl font-black'>热门搜索</h2>
|
||||
<div className='mt-6 grid grid-cols-2 gap-4 md:grid-cols-4'>
|
||||
{hot.map((item) => (
|
||||
<button key={item} onClick={() => router.push(`/tv/play?title=${encodeURIComponent(item)}`)} className='cursor-pointer rounded-3xl border border-white/10 bg-white/[0.06] px-6 py-5 text-2xl font-bold text-white outline-none transition hover:bg-white/12 tv-focusable'>
|
||||
{item}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</TVLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import TVBrowsePage from '@/components/tv/TVBrowsePage';
|
||||
|
||||
export default function TVSeriesPage() {
|
||||
return <TVBrowsePage title='剧集' subtitle='热播、更新、国产剧、日韩美剧集中浏览。' sections={[
|
||||
{ title: '热播剧集', kind: 'tv', tag: '热门', type: 'tv' },
|
||||
{ title: '国产剧', kind: 'tv', tag: '国产剧', type: 'tv' },
|
||||
{ title: '美剧', kind: 'tv', tag: '美剧', type: 'tv' },
|
||||
{ title: '韩剧', kind: 'tv', tag: '韩剧', type: 'tv' },
|
||||
]} />;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import TVBrowsePage from '@/components/tv/TVBrowsePage';
|
||||
|
||||
export default function TVVarietyPage() {
|
||||
return <TVBrowsePage title='综艺' subtitle='客厅下饭综艺入口,减少筛选,直接开看。' sections={[
|
||||
{ title: '热门综艺', kind: 'tv', tag: '综艺', type: 'tv' },
|
||||
{ title: '大陆综艺', kind: 'tv', tag: '大陆综艺', type: 'tv' },
|
||||
{ title: '韩国综艺', kind: 'tv', tag: '韩国综艺', type: 'tv' },
|
||||
{ title: '脱口秀', kind: 'tv', tag: '脱口秀', type: 'tv' },
|
||||
]} />;
|
||||
}
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import crypto from 'crypto';
|
||||
|
||||
export type QrLoginStatus = 'pending' | 'scanned' | 'confirmed' | 'expired' | 'cancelled' | 'used';
|
||||
|
||||
export interface QrLoginSession {
|
||||
token: string;
|
||||
status: QrLoginStatus;
|
||||
createdAt: number;
|
||||
expiresAt: number;
|
||||
authToken?: string;
|
||||
userAgent?: string;
|
||||
}
|
||||
|
||||
type GlobalWithQr = typeof globalThis & { __moonTvQrLoginStore?: Map<string, QrLoginSession> };
|
||||
|
||||
const g = globalThis as GlobalWithQr;
|
||||
export const qrLoginStore = g.__moonTvQrLoginStore || new Map<string, QrLoginSession>();
|
||||
g.__moonTvQrLoginStore = qrLoginStore;
|
||||
|
||||
export function createQrLoginSession(ttlMs = 120_000) {
|
||||
cleanupQrLoginSessions();
|
||||
const token = crypto.randomBytes(24).toString('base64url');
|
||||
const now = Date.now();
|
||||
const session: QrLoginSession = {
|
||||
token,
|
||||
status: 'pending',
|
||||
createdAt: now,
|
||||
expiresAt: now + ttlMs,
|
||||
};
|
||||
qrLoginStore.set(token, session);
|
||||
return session;
|
||||
}
|
||||
|
||||
export function getQrLoginSession(token?: string | null) {
|
||||
if (!token) return null;
|
||||
const session = qrLoginStore.get(token) || null;
|
||||
if (session && session.expiresAt <= Date.now() && session.status !== 'confirmed' && session.status !== 'used') {
|
||||
session.status = 'expired';
|
||||
}
|
||||
return session;
|
||||
}
|
||||
|
||||
export function cleanupQrLoginSessions() {
|
||||
const now = Date.now();
|
||||
for (const [token, session] of Array.from(qrLoginStore.entries())) {
|
||||
if (session.expiresAt + 300_000 < now || session.status === 'used') {
|
||||
qrLoginStore.delete(token);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user