继续完善tv模式

This commit is contained in:
mtvpls
2026-05-29 01:06:35 +08:00
parent 6e5fb5796c
commit 134628f773
12 changed files with 909 additions and 53 deletions
+1 -1
View File
@@ -10,7 +10,7 @@ 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)}${
const playUrl = item.href || `/tv/detail?title=${encodeURIComponent(item.title)}${
item.year ? `&year=${encodeURIComponent(item.year)}` : ''
}${item.type ? `&stype=${item.type}` : ''}`;
+15 -10
View File
@@ -38,16 +38,15 @@ function getFocusableElements() {
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',
});
const isInFixedChrome = Boolean(element.closest('header, [data-tv-remote], [data-tv-player-control], [data-tv-player-root]'));
// 播放页浮层是 fixed/absolute,不能 scrollIntoView,否则会把全屏播放器滚出视口。
if (!isInFixedChrome) {
element.scrollIntoView({
block: 'nearest',
inline: 'nearest',
behavior: 'smooth',
});
window.requestAnimationFrame(() => {
const rect = element.getBoundingClientRect();
const safeTop = 150;
@@ -218,8 +217,14 @@ export default function TVVirtualRemote() {
}
if (event.key === 'Escape') {
event.preventDefault();
window.history.back();
const path = window.location.pathname;
const playerPage = path === '/tv/play' || path === '/tv/live/play';
// 播放页需要优先用返回键关闭选集/频道面板,不能被全局遥控器直接 history.back。
if (!playerPage) {
event.preventDefault();
window.history.back();
}
return;
}
if (event.key === 'Home') {
+194
View File
@@ -0,0 +1,194 @@
'use client';
import { Loader2, Pause, Play } from 'lucide-react';
import { useEffect, useRef, useState } from 'react';
declare global {
interface HTMLVideoElement {
hls?: any;
flv?: any;
}
}
function getSourceType(url: string): 'm3u8' | 'flv' | 'native' {
const lower = url.toLowerCase().split('?')[0];
if (lower.includes('.m3u8') || lower.includes('.m3u')) return 'm3u8';
if (lower.endsWith('.flv') || url.toLowerCase().includes('.flv?')) return 'flv';
return 'native';
}
export default function TVNativeVideo({
url,
poster,
live = false,
title,
onTime,
command,
className = '',
}: {
url: string;
poster?: string;
live?: boolean;
title?: string;
onTime?: (current: number, duration: number) => void;
command?: number;
className?: string;
}) {
const videoRef = useRef<HTMLVideoElement | null>(null);
const [loading, setLoading] = useState(false);
const [playing, setPlaying] = useState(false);
const [error, setError] = useState('');
useEffect(() => {
const video = videoRef.current;
if (!video || !url) return;
const videoEl = video;
let disposed = false;
setLoading(true);
setError('');
setPlaying(false);
const cleanup = () => {
if (videoEl.hls) {
videoEl.hls.destroy();
videoEl.hls = null;
}
if (videoEl.flv) {
videoEl.flv.destroy();
videoEl.flv = null;
}
videoEl.removeAttribute('src');
videoEl.load();
};
const playSafely = () => {
videoEl.play().catch(() => {
// 浏览器阻止自动播放时,等待用户按 OK/点击播放
});
};
async function attach() {
cleanup();
const type = getSourceType(url);
try {
if (type === 'm3u8' && !videoEl.canPlayType('application/vnd.apple.mpegurl')) {
const HlsModule = await import('hls.js');
if (disposed) return;
const Hls = HlsModule.default;
if (Hls.isSupported()) {
const hls = new Hls({
enableWorker: true,
lowLatencyMode: live,
backBufferLength: live ? 10 : 30,
maxBufferLength: live ? 18 : 45,
});
hls.loadSource(url);
hls.attachMedia(videoEl);
videoEl.hls = hls;
} else {
videoEl.src = url;
}
} else if (type === 'flv') {
const flvModule = await import('flv.js');
if (disposed) return;
const flvjs = flvModule.default;
if (flvjs.isSupported()) {
const flv = flvjs.createPlayer({ type: 'flv', url, isLive: live });
flv.attachMediaElement(videoEl);
flv.load();
videoEl.flv = flv;
} else {
videoEl.src = url;
}
} else {
videoEl.src = url;
}
videoEl.setAttribute('playsinline', 'true');
videoEl.setAttribute('webkit-playsinline', 'true');
videoEl.muted = false;
playSafely();
} catch (err) {
console.error('[TVNativeVideo] attach failed:', err);
setError('播放器初始化失败');
setLoading(false);
}
}
attach();
const onLoaded = () => setLoading(false);
const onPlay = () => setPlaying(true);
const onPause = () => setPlaying(false);
const onError = () => {
setLoading(false);
setError('视频加载失败,请尝试切换线路或频道');
};
const onTimeUpdate = () => onTime?.(videoEl.currentTime || 0, videoEl.duration || 0);
videoEl.addEventListener('loadeddata', onLoaded);
videoEl.addEventListener('canplay', onLoaded);
videoEl.addEventListener('play', onPlay);
videoEl.addEventListener('pause', onPause);
videoEl.addEventListener('error', onError);
videoEl.addEventListener('timeupdate', onTimeUpdate);
return () => {
disposed = true;
videoEl.removeEventListener('loadeddata', onLoaded);
videoEl.removeEventListener('canplay', onLoaded);
videoEl.removeEventListener('play', onPlay);
videoEl.removeEventListener('pause', onPause);
videoEl.removeEventListener('error', onError);
videoEl.removeEventListener('timeupdate', onTimeUpdate);
cleanup();
};
}, [url, live, onTime]);
const toggle = () => {
const video = videoRef.current;
if (!video) return;
if (video.paused) video.play().catch(() => undefined);
else video.pause();
};
useEffect(() => {
if (command) toggle();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [command]);
return (
<div className={`relative h-full w-full bg-black ${className}`}>
<video
ref={videoRef}
poster={poster}
className='h-full w-full bg-black object-contain'
controls={false}
playsInline
preload='auto'
onClick={toggle}
aria-label={title || 'TV 视频播放器'}
/>
{loading && (
<div className='pointer-events-none absolute inset-0 flex items-center justify-center bg-black/35 text-2xl font-bold text-white'>
<Loader2 className='mr-3 h-9 w-9 animate-spin text-rose-500' /> ...
</div>
)}
{error && (
<div className='absolute inset-0 flex items-center justify-center bg-black/70 p-8 text-center text-3xl font-black text-white'>
{error}
</div>
)}
<button
type='button'
onClick={toggle}
className='tv-focusable absolute left-1/2 top-1/2 flex h-24 w-24 -translate-x-1/2 -translate-y-1/2 cursor-pointer items-center justify-center rounded-full bg-black/35 text-white opacity-0 outline-none backdrop-blur transition hover:opacity-100 focus:opacity-100'
aria-label={playing ? '暂停' : '播放'}
>
{playing ? <Pause className='h-12 w-12' /> : <Play className='h-12 w-12 fill-current' />}
</button>
</div>
);
}
+69
View File
@@ -0,0 +1,69 @@
import { SearchResult } from '@/lib/types';
export async function fetchTVDetail(params: {
source?: string | null;
id?: string | null;
title?: string | null;
fileName?: string | null;
}): Promise<{ detail: SearchResult; sources: SearchResult[] }> {
const { source, id, title, fileName } = params;
if (source && id) {
const qs = new URLSearchParams({ source, id, title: title || '' });
if (fileName) qs.set('fileName', fileName);
const res = await fetch(`/api/source-detail?${qs.toString()}`, { cache: 'no-store' });
if (!res.ok) throw new Error('获取视频详情失败');
const detail = (await res.json()) as SearchResult;
return { detail, sources: [detail] };
}
if (!title) throw new Error('缺少片名');
const res = await fetch(`/api/search?q=${encodeURIComponent(title)}`, { cache: 'no-store' });
if (!res.ok) throw new Error('搜索播放源失败');
const data = await res.json();
const sources = (data.results || []) as SearchResult[];
if (sources.length === 0) throw new Error('未找到播放源');
let detail = sources[0];
if (!detail.episodes?.length) {
const qs = new URLSearchParams({ source: detail.source, id: detail.id, title: detail.title || title });
const detailRes = await fetch(`/api/source-detail?${qs.toString()}`, { cache: 'no-store' });
if (detailRes.ok) detail = (await detailRes.json()) as SearchResult;
}
return { detail, sources };
}
export async function resolveTVEpisodeUrl(rawUrl: string, source?: string, proxyMode?: boolean) {
let url = rawUrl;
const lazyPrefixes = [
'/api/xiaoya/play',
'/api/openlist/play',
'/api/netdisk/115/play',
'/api/netdisk/123/play',
'/api/netdisk/quark/play',
'/api/netdisk/uc/play',
'/api/netdisk/baidu/play',
'/api/source-script/play',
];
if (lazyPrefixes.some((prefix) => url.startsWith(prefix))) {
const separator = url.includes('?') ? '&' : '?';
const res = await fetch(`${url}${separator}format=json`, { cache: 'no-store' });
const data = await res.json();
if (data.url) url = data.url;
}
const isM3u8 = url.toLowerCase().includes('.m3u') || !url.toLowerCase().match(/\.(mp4|flv|webm|mkv|avi|mov)(\?.*)?$/);
if (proxyMode && source && isM3u8 && !url.startsWith('/api/proxy/')) {
return `/api/proxy/vod/m3u8?url=${encodeURIComponent(url)}&source=${encodeURIComponent(source)}`;
}
return url;
}
export function formatTVTime(seconds: number) {
if (!Number.isFinite(seconds) || seconds <= 0) return '00:00';
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = Math.floor(seconds % 60);
return h > 0
? `${h.toString().padStart(2, '0')}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`
: `${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`;
}