继续完善tv播放页

This commit is contained in:
mtvpls
2026-05-29 22:22:12 +08:00
parent 134628f773
commit b121b328c3
7 changed files with 1040 additions and 83 deletions
+37 -6
View File
@@ -128,11 +128,12 @@ const keys = {
home: { key: 'Home', code: 'Home', keyCode: 36 },
};
function fireRemoteKey(name: keyof typeof keys) {
function fireRemoteKey(name: keyof typeof keys, repeat = false) {
const cfg = keys[name];
const eventInit: KeyboardEventInit = {
key: cfg.key,
code: cfg.code,
repeat,
bubbles: true,
cancelable: true,
};
@@ -157,20 +158,50 @@ function fireRemoteKey(name: keyof typeof keys) {
function RemoteButton({
label,
onClick,
onRepeat,
repeatable = false,
className = '',
children,
}: {
label: string;
onClick: () => void;
onRepeat?: () => void;
repeatable?: boolean;
className?: string;
children: React.ReactNode;
}) {
const delayRef = useRef<number | null>(null);
const intervalRef = useRef<number | null>(null);
const clearRepeat = () => {
if (delayRef.current) window.clearTimeout(delayRef.current);
if (intervalRef.current) window.clearInterval(intervalRef.current);
delayRef.current = null;
intervalRef.current = null;
};
useEffect(() => clearRepeat, []);
return (
<button
type='button'
aria-label={label}
title={label}
onClick={onClick}
onClick={() => {
if (!repeatable) onClick();
}}
onPointerDown={(event) => {
event.preventDefault();
if (!repeatable) return;
onClick();
clearRepeat();
delayRef.current = window.setTimeout(() => {
intervalRef.current = window.setInterval(onRepeat || onClick, 130);
}, 360);
}}
onPointerUp={clearRepeat}
onPointerCancel={clearRepeat}
onPointerLeave={clearRepeat}
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}`}
>
@@ -267,23 +298,23 @@ export default function TVVirtualRemote() {
</RemoteButton>
<div />
<RemoteButton label='上' onClick={() => fireRemoteKey('up')} className='h-16'>
<RemoteButton label='上' onClick={() => fireRemoteKey('up')} onRepeat={() => fireRemoteKey('up', true)} repeatable className='h-16'>
<ChevronUp className='h-9 w-9' />
</RemoteButton>
<div />
<RemoteButton label='左' onClick={() => fireRemoteKey('left')} className='h-16'>
<RemoteButton label='左' onClick={() => fireRemoteKey('left')} onRepeat={() => fireRemoteKey('left', true)} repeatable 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'>
<RemoteButton label='右' onClick={() => fireRemoteKey('right')} onRepeat={() => fireRemoteKey('right', true)} repeatable className='h-16'>
<ChevronRight className='h-9 w-9' />
</RemoteButton>
<div />
<RemoteButton label='下' onClick={() => fireRemoteKey('down')} className='h-16'>
<RemoteButton label='下' onClick={() => fireRemoteKey('down')} onRepeat={() => fireRemoteKey('down', true)} repeatable className='h-16'>
<ChevronDown className='h-9 w-9' />
</RemoteButton>
<div />
+115 -8
View File
@@ -11,18 +11,54 @@ declare global {
}
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';
const lower = url.toLowerCase();
const path = lower.split('?')[0];
// 代理地址通常是 /api/proxy/vod/m3u8?url=...,真实 m3u8 在 query 中;
// 不能只看 ? 前路径,否则会被当成 native,浏览器只请求 index.m3u8 而不会交给 hls.js 拉 ts。
if (path.includes('.m3u8') || path.includes('.m3u') || lower.includes('/m3u8') || lower.includes('m3u8') || lower.includes('.m3u')) return 'm3u8';
if (path.endsWith('.flv') || lower.includes('.flv?')) return 'flv';
return 'native';
}
function filterAdsFromM3U8(m3u8Content: string): string {
if (!m3u8Content) return '';
const adKeywords = ['sponsor', '/ad/', '/ads/', 'advert', 'advertisement', '/adjump', 'redtraffic'];
const lines = m3u8Content.split('\n');
const filteredLines: string[] = [];
let i = 0;
while (i < lines.length) {
const line = lines[i];
if (line.includes('#EXT-X-DISCONTINUITY')) {
i++;
continue;
}
if (line.includes('#EXTINF:') && i + 1 < lines.length) {
const nextLine = lines[i + 1];
const isAd = adKeywords.some((keyword) => nextLine.toLowerCase().includes(keyword));
if (isAd) {
i += 2;
continue;
}
}
filteredLines.push(line);
i++;
}
return filteredLines.join('\n');
}
export default function TVNativeVideo({
url,
poster,
live = false,
title,
onTime,
onError: onPlaybackError,
onPlayingChange,
adFilterEnabled = false,
playbackRate = 1,
startTime = 0,
command,
className = '',
}: {
@@ -31,14 +67,34 @@ export default function TVNativeVideo({
live?: boolean;
title?: string;
onTime?: (current: number, duration: number) => void;
onError?: () => void;
onPlayingChange?: (playing: boolean) => void;
adFilterEnabled?: boolean;
playbackRate?: number;
startTime?: number;
command?: number;
className?: string;
}) {
const videoRef = useRef<HTMLVideoElement | null>(null);
const onTimeRef = useRef<typeof onTime>(onTime);
const onPlaybackErrorRef = useRef<typeof onPlaybackError>(onPlaybackError);
const onPlayingChangeRef = useRef<typeof onPlayingChange>(onPlayingChange);
const [loading, setLoading] = useState(false);
const [playing, setPlaying] = useState(false);
const [error, setError] = useState('');
useEffect(() => {
onTimeRef.current = onTime;
}, [onTime]);
useEffect(() => {
onPlaybackErrorRef.current = onPlaybackError;
}, [onPlaybackError]);
useEffect(() => {
onPlayingChangeRef.current = onPlayingChange;
}, [onPlayingChange]);
useEffect(() => {
const video = videoRef.current;
if (!video || !url) return;
@@ -78,11 +134,32 @@ export default function TVNativeVideo({
if (disposed) return;
const Hls = HlsModule.default;
if (Hls.isSupported()) {
const CustomLoader = adFilterEnabled
? class TVAdFilterLoader extends Hls.DefaultConfig.loader {
constructor(config: any) {
super(config);
const load = this.load.bind(this);
this.load = (context: any, config: any, callbacks: any) => {
if (context?.type === 'manifest' || context?.type === 'level') {
const onSuccess = callbacks.onSuccess;
callbacks.onSuccess = (response: any, stats: any, context: any, networkDetails: any) => {
if (typeof response?.data === 'string') {
response.data = filterAdsFromM3U8(response.data);
}
return onSuccess(response, stats, context, networkDetails);
};
}
load(context, config, callbacks);
};
}
}
: undefined;
const hls = new Hls({
enableWorker: true,
lowLatencyMode: live,
backBufferLength: live ? 10 : 30,
maxBufferLength: live ? 18 : 45,
...(CustomLoader ? { loader: CustomLoader } : {}),
});
hls.loadSource(url);
hls.attachMedia(videoEl);
@@ -108,6 +185,7 @@ export default function TVNativeVideo({
videoEl.setAttribute('playsinline', 'true');
videoEl.setAttribute('webkit-playsinline', 'true');
videoEl.playbackRate = playbackRate;
videoEl.muted = false;
playSafely();
} catch (err) {
@@ -119,15 +197,38 @@ export default function TVNativeVideo({
attach();
const onLoaded = () => setLoading(false);
const onPlay = () => setPlaying(true);
const onPause = () => setPlaying(false);
let seekedInitialTime = false;
const seekToInitialTime = () => {
if (live || seekedInitialTime || !startTime || startTime <= 1) return;
const duration = videoEl.duration || 0;
const safeTime = duration > 30 ? Math.min(startTime, Math.max(0, duration - 8)) : startTime;
try {
videoEl.currentTime = safeTime;
seekedInitialTime = true;
} catch {
// ignore unsupported seek state
}
};
const onLoaded = () => {
seekToInitialTime();
setLoading(false);
};
const onPlay = () => {
setPlaying(true);
onPlayingChangeRef.current?.(true);
};
const onPause = () => {
setPlaying(false);
onPlayingChangeRef.current?.(false);
};
const onError = () => {
setLoading(false);
setError('视频加载失败,请尝试切换线路或频道');
onPlaybackErrorRef.current?.();
};
const onTimeUpdate = () => onTime?.(videoEl.currentTime || 0, videoEl.duration || 0);
const onTimeUpdate = () => onTimeRef.current?.(videoEl.currentTime || 0, videoEl.duration || 0);
videoEl.addEventListener('loadedmetadata', seekToInitialTime);
videoEl.addEventListener('loadeddata', onLoaded);
videoEl.addEventListener('canplay', onLoaded);
videoEl.addEventListener('play', onPlay);
@@ -137,6 +238,7 @@ export default function TVNativeVideo({
return () => {
disposed = true;
videoEl.removeEventListener('loadedmetadata', seekToInitialTime);
videoEl.removeEventListener('loadeddata', onLoaded);
videoEl.removeEventListener('canplay', onLoaded);
videoEl.removeEventListener('play', onPlay);
@@ -145,7 +247,12 @@ export default function TVNativeVideo({
videoEl.removeEventListener('timeupdate', onTimeUpdate);
cleanup();
};
}, [url, live, onTime]);
}, [url, live, startTime, adFilterEnabled, playbackRate]);
useEffect(() => {
const video = videoRef.current;
if (video) video.playbackRate = playbackRate;
}, [playbackRate]);
const toggle = () => {
const video = videoRef.current;
+18 -1
View File
@@ -14,7 +14,24 @@ export async function fetchTVDetail(params: {
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] };
let sources: SearchResult[] = [detail];
const searchTitle = title || detail.title;
if (searchTitle) {
try {
const searchRes = await fetch(`/api/search?q=${encodeURIComponent(searchTitle)}`, { cache: 'no-store' });
if (searchRes.ok) {
const data = await searchRes.json();
const list = (data.results || []) as SearchResult[];
sources = [
detail,
...list.filter((item) => !(item.source === detail.source && item.id === detail.id)),
];
}
} catch {
// 换源搜索失败不影响当前播放
}
}
return { detail, sources };
}
if (!title) throw new Error('缺少片名');