继续完善tv播放页
This commit is contained in:
@@ -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 />
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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('缺少片名');
|
||||
|
||||
Reference in New Issue
Block a user