From 6e5fb5796cb70e09bc780b14adde4358b0ac8e08 Mon Sep 17 00:00:00 2001 From: mtvpls Date: Fri, 29 May 2026 00:10:55 +0800 Subject: [PATCH] =?UTF-8?q?tv=E6=A8=A1=E5=BC=8F=E5=88=9D=E7=89=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/api/auth/qr/cancel/route.ts | 12 ++ src/app/api/auth/qr/confirm/route.ts | 22 ++ src/app/api/auth/qr/create/route.ts | 12 ++ src/app/api/auth/qr/status/route.ts | 28 +++ src/app/globals.css | 55 +++++ src/app/qr-login/page.tsx | 64 ++++++ src/app/tv/TVHomeClient.tsx | 117 +++++++++++ src/app/tv/anime/page.tsx | 154 ++++++++++++++ src/app/tv/layout.tsx | 9 + src/app/tv/live/page.tsx | 75 +++++++ src/app/tv/live/play/page.tsx | 35 +++ src/app/tv/login/page.tsx | 75 +++++++ src/app/tv/movie/page.tsx | 10 + src/app/tv/page.tsx | 5 + src/app/tv/play/page.tsx | 49 +++++ src/app/tv/private/page.tsx | 144 +++++++++++++ src/app/tv/search/page.tsx | 75 +++++++ src/app/tv/series/page.tsx | 10 + src/app/tv/variety/page.tsx | 10 + src/components/tv/TVBrowsePage.tsx | 93 ++++++++ src/components/tv/TVCard.tsx | 42 ++++ src/components/tv/TVLayout.tsx | 65 ++++++ src/components/tv/TVRow.tsx | 28 +++ src/components/tv/TVVirtualRemote.tsx | 292 ++++++++++++++++++++++++++ src/components/tv/types.ts | 17 ++ src/lib/qr-login/store.ts | 50 +++++ 26 files changed, 1548 insertions(+) create mode 100644 src/app/api/auth/qr/cancel/route.ts create mode 100644 src/app/api/auth/qr/confirm/route.ts create mode 100644 src/app/api/auth/qr/create/route.ts create mode 100644 src/app/api/auth/qr/status/route.ts create mode 100644 src/app/qr-login/page.tsx create mode 100644 src/app/tv/TVHomeClient.tsx create mode 100644 src/app/tv/anime/page.tsx create mode 100644 src/app/tv/layout.tsx create mode 100644 src/app/tv/live/page.tsx create mode 100644 src/app/tv/live/play/page.tsx create mode 100644 src/app/tv/login/page.tsx create mode 100644 src/app/tv/movie/page.tsx create mode 100644 src/app/tv/page.tsx create mode 100644 src/app/tv/play/page.tsx create mode 100644 src/app/tv/private/page.tsx create mode 100644 src/app/tv/search/page.tsx create mode 100644 src/app/tv/series/page.tsx create mode 100644 src/app/tv/variety/page.tsx create mode 100644 src/components/tv/TVBrowsePage.tsx create mode 100644 src/components/tv/TVCard.tsx create mode 100644 src/components/tv/TVLayout.tsx create mode 100644 src/components/tv/TVRow.tsx create mode 100644 src/components/tv/TVVirtualRemote.tsx create mode 100644 src/components/tv/types.ts create mode 100644 src/lib/qr-login/store.ts diff --git a/src/app/api/auth/qr/cancel/route.ts b/src/app/api/auth/qr/cancel/route.ts new file mode 100644 index 0000000..8403b6a --- /dev/null +++ b/src/app/api/auth/qr/cancel/route.ts @@ -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 }); +} diff --git a/src/app/api/auth/qr/confirm/route.ts b/src/app/api/auth/qr/confirm/route.ts new file mode 100644 index 0000000..45a5ab7 --- /dev/null +++ b/src/app/api/auth/qr/confirm/route.ts @@ -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' }); +} diff --git a/src/app/api/auth/qr/create/route.ts b/src/app/api/auth/qr/create/route.ts new file mode 100644 index 0000000..8a68be3 --- /dev/null +++ b/src/app/api/auth/qr/create/route.ts @@ -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 }); +} diff --git a/src/app/api/auth/qr/status/route.ts b/src/app/api/auth/qr/status/route.ts new file mode 100644 index 0000000..9e416b8 --- /dev/null +++ b/src/app/api/auth/qr/status/route.ts @@ -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 }); +} diff --git a/src/app/globals.css b/src/app/globals.css index 92be39e..3c5b7d6 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -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; + } +} diff --git a/src/app/qr-login/page.tsx b/src/app/qr-login/page.tsx new file mode 100644 index 0000000..c949b9f --- /dev/null +++ b/src/app/qr-login/page.tsx @@ -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 ( +
+
+

确认登录电视端

+

请确认电视屏幕上的二维码来自你正在使用的设备。

+ {!authed ? ( +
+ +

当前手机未登录,请先登录后再确认电视登录。

+ 去登录 +
+ ) : ( +
+ + +
+ )} + {message &&

{message}

} +
+
+ ); +} + +export default function QrLoginPage() { + return ; +} diff --git a/src/app/tv/TVHomeClient.tsx b/src/app/tv/TVHomeClient.tsx new file mode 100644 index 0000000..01f8daf --- /dev/null +++ b/src/app/tv/TVHomeClient.tsx @@ -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 { + 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([]); + 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 ( + + {loading ? ( +
+ 正在加载电视首页... +
+ ) : empty ? ( +
暂无首页内容
+ ) : ( + sections.map((section) => ) + )} +
+ ); +} diff --git a/src/app/tv/anime/page.tsx b/src/app/tv/anime/page.tsx new file mode 100644 index 0000000..23d4f47 --- /dev/null +++ b/src/app/tv/anime/page.tsx @@ -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 = { + Mon: '周一', + Tue: '周二', + Wed: '周三', + Thu: '周四', + Fri: '周五', + Sat: '周六', + Sun: '周日', +}; + +async function loadDouban(kind: 'movie' | 'tv', tag: string, type: TVItem['type']): Promise { + 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([]); + const [rows, setRows] = useState([]); + 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 ( + +
+
+ +
+

动漫更新时间表

+

按周查看新番放送,遥控器左右选择日期。

+
+
+ +
+ {calendar.map((day) => ( + + ))} +
+
+ + {loading ? ( +
+ 正在加载动漫内容... +
+ ) : ( + <> +
+
+
+

+ {weekdayMap[activeDay] || activeDay} 更新 +

+

当天放送的新番列表

+
+
+ {activeItems.length > 0 ? ( +
+ {activeItems.map((item) => )} +
+ ) : ( +
暂无更新时间表数据
+ )} +
+ {rows.map((section) => )} + + )} +
+ ); +} diff --git a/src/app/tv/layout.tsx b/src/app/tv/layout.tsx new file mode 100644 index 0000000..0c7d762 --- /dev/null +++ b/src/app/tv/layout.tsx @@ -0,0 +1,9 @@ +import { ReactNode } from 'react'; + +export const metadata = { + title: 'TV - MoonTV Plus', +}; + +export default function Layout({ children }: { children: ReactNode }) { + return children; +} diff --git a/src/app/tv/live/page.tsx b/src/app/tv/live/page.tsx new file mode 100644 index 0000000..2af727a --- /dev/null +++ b/src/app/tv/live/page.tsx @@ -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([]); + const [source, setSource] = useState(''); + const [channels, setChannels] = useState([]); + 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 ( + +
+
+ +
+

直播

+

选择频道后进入全屏直播播放页,频道列表作为播放层弹出。

+
+
+
+ {sources.map((item) => ( + + ))} +
+
+ + {loading ?
正在加载频道...
: ( +
+ +
+ {channels.slice(0, 80).map((channel) => ( + + ))} +
+
+ )} +
+ ); +} diff --git a/src/app/tv/live/play/page.tsx b/src/app/tv/live/play/page.tsx new file mode 100644 index 0000000..970cc71 --- /dev/null +++ b/src/app/tv/live/play/page.tsx @@ -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 ( +
setShowPanel(true)}> +