mirror of
https://github.com/KuekHaoYang/KVideo.git
synced 2026-08-12 23:33:43 +08:00
1072 lines
40 KiB
TypeScript
1072 lines
40 KiB
TypeScript
'use client';
|
|
|
|
/**
|
|
* IPTVPlayer - Player for IPTV streams with controls, volume, progress, and sidebar.
|
|
* Supports HLS (via HLS.js), native HLS (Safari), and direct video playback.
|
|
* Features multi-level sidebar (source -> group -> channels), multi-route collapse,
|
|
* and optimized search performance.
|
|
*/
|
|
|
|
import { useRef, useEffect, useState, useCallback, useMemo, useTransition } from 'react';
|
|
import Hls from 'hls.js';
|
|
import { Icons } from '@/components/ui/Icon';
|
|
import type { M3UChannel } from '@/lib/utils/m3u-parser';
|
|
import type { IPTVSource } from '@/lib/store/iptv-store';
|
|
import { settingsStore, DEFAULT_SEEK_STEP_SECONDS } from '@/lib/store/settings-store';
|
|
import { shouldHidePlayerCursor } from '@/lib/player/cursor-visibility';
|
|
|
|
const HLS_LIVE_CONFIG: Partial<Hls['config']> = {
|
|
enableWorker: true,
|
|
lowLatencyMode: true,
|
|
liveDurationInfinity: true,
|
|
manifestLoadingTimeOut: 10000,
|
|
manifestLoadingMaxRetry: 3,
|
|
levelLoadingTimeOut: 10000,
|
|
fragLoadingTimeOut: 20000,
|
|
// Prefer H.264 (avc) over HEVC (hev/hvc) for maximum browser compatibility
|
|
preferManagedMediaSource: false,
|
|
};
|
|
|
|
const LOADING_TIMEOUT_MS = 30000;
|
|
const MAX_VISIBLE_ROUTES = 3;
|
|
|
|
interface IPTVPlayerProps {
|
|
channel: M3UChannel;
|
|
onClose: () => void;
|
|
channels: M3UChannel[];
|
|
onChannelChange: (channel: M3UChannel) => void;
|
|
channelsBySource?: Record<string, { channels: M3UChannel[]; groups: string[] }>;
|
|
sources?: IPTVSource[];
|
|
}
|
|
|
|
function getProxiedUrl(url: string, ua?: string, referer?: string): string {
|
|
let proxyUrl = `/api/iptv/stream?`;
|
|
if (ua) proxyUrl += `ua=${encodeURIComponent(ua)}&`;
|
|
if (referer) proxyUrl += `referer=${encodeURIComponent(referer)}&`;
|
|
proxyUrl += `url=${encodeURIComponent(url)}`;
|
|
return proxyUrl;
|
|
}
|
|
|
|
function formatTime(seconds: number): string {
|
|
if (!isFinite(seconds) || seconds < 0) return '0:00';
|
|
const h = Math.floor(seconds / 3600);
|
|
const m = Math.floor((seconds % 3600) / 60);
|
|
const s = Math.floor(seconds % 60);
|
|
if (h > 0) return `${h}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`;
|
|
return `${m}:${s.toString().padStart(2, '0')}`;
|
|
}
|
|
|
|
function getSeekRange(video: HTMLVideoElement): { start: number; end: number; duration: number } | null {
|
|
if (video.seekable.length > 0) {
|
|
const start = video.seekable.start(0);
|
|
const end = video.seekable.end(video.seekable.length - 1);
|
|
if (isFinite(start) && isFinite(end) && end > start) {
|
|
return { start, end, duration: end - start };
|
|
}
|
|
}
|
|
|
|
if (isFinite(video.duration) && video.duration > 0) {
|
|
return { start: 0, end: video.duration, duration: video.duration };
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
export function IPTVPlayer({ channel, onClose, channels, onChannelChange, channelsBySource, sources }: IPTVPlayerProps) {
|
|
const videoRef = useRef<HTMLVideoElement>(null);
|
|
const hlsRef = useRef<Hls | null>(null);
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
const activeChannelRef = useRef<HTMLButtonElement>(null);
|
|
const controlsTimeoutRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
|
const loadingTimeoutRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
|
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [showSidebar, setShowSidebar] = useState(false);
|
|
const [sidebarSearch, setSidebarSearch] = useState('');
|
|
const [filteredResults, setFilteredResults] = useState<M3UChannel[]>([]);
|
|
const [isSearching, startSearchTransition] = useTransition();
|
|
const [sidebarVisibleCount, setSidebarVisibleCount] = useState(50);
|
|
const [isLoading, setIsLoading] = useState(true);
|
|
const [isPlaying, setIsPlaying] = useState(false);
|
|
const [showControls, setShowControls] = useState(true);
|
|
const [isLive, setIsLive] = useState(true);
|
|
const [currentTime, setCurrentTime] = useState(0);
|
|
const [duration, setDuration] = useState(0);
|
|
const [seekWindow, setSeekWindow] = useState<{ start: number; end: number; duration: number } | null>(null);
|
|
const [volume, setVolume] = useState(1);
|
|
const [isMuted, setIsMuted] = useState(false);
|
|
const [showVolumeSlider, setShowVolumeSlider] = useState(false);
|
|
const [currentRouteIndex, setCurrentRouteIndex] = useState(0);
|
|
const [isFullscreen, setIsFullscreen] = useState(false);
|
|
const [showAllRoutes, setShowAllRoutes] = useState(false);
|
|
const [seekStepSeconds, setSeekStepSeconds] = useState(DEFAULT_SEEK_STEP_SECONDS);
|
|
|
|
// Multi-level sidebar state
|
|
const [expandedSources, setExpandedSources] = useState<Set<string>>(new Set());
|
|
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set());
|
|
|
|
// Whether we have multi-source data
|
|
const hasMultiSource = channelsBySource && sources && sources.length > 0;
|
|
const activeSourceId = channel.sourceId || null;
|
|
const activeGroupKey = activeSourceId && channel.group ? `${activeSourceId}::${channel.group}` : null;
|
|
const activeSource = useMemo(
|
|
() => (activeSourceId && sources ? sources.find((source) => source.id === activeSourceId) || null : null),
|
|
[activeSourceId, sources]
|
|
);
|
|
|
|
// Get current route URL
|
|
const routes = channel.routes || [channel.url];
|
|
const currentUrl = routes[currentRouteIndex] || channel.url;
|
|
|
|
// Route display - collapse if > MAX_VISIBLE_ROUTES
|
|
const visibleRoutes = showAllRoutes ? routes : routes.slice(0, MAX_VISIBLE_ROUTES);
|
|
const hasMoreRoutes = routes.length > MAX_VISIBLE_ROUTES;
|
|
|
|
// Auto-scroll to active channel in sidebar
|
|
useEffect(() => {
|
|
if (showSidebar && activeChannelRef.current) {
|
|
activeChannelRef.current.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
}
|
|
}, [showSidebar, channel.url]);
|
|
|
|
useEffect(() => {
|
|
const syncSeekStep = () => {
|
|
setSeekStepSeconds(settingsStore.getSettings().seekStepSeconds ?? DEFAULT_SEEK_STEP_SECONDS);
|
|
};
|
|
|
|
syncSeekStep();
|
|
const unsubscribe = settingsStore.subscribe(syncSeekStep);
|
|
return () => unsubscribe();
|
|
}, []);
|
|
|
|
// Auto-expand the source/group containing the active channel
|
|
useEffect(() => {
|
|
if (channel.sourceId) {
|
|
setExpandedSources(prev => new Set(prev).add(channel.sourceId!));
|
|
if (channel.group) {
|
|
setExpandedGroups(prev => new Set(prev).add(`${channel.sourceId}::${channel.group}`));
|
|
}
|
|
}
|
|
}, [channel.sourceId, channel.group]);
|
|
|
|
// Track fullscreen changes
|
|
useEffect(() => {
|
|
const handleFullscreenChange = () => {
|
|
setIsFullscreen(!!document.fullscreenElement);
|
|
};
|
|
document.addEventListener('fullscreenchange', handleFullscreenChange);
|
|
return () => document.removeEventListener('fullscreenchange', handleFullscreenChange);
|
|
}, []);
|
|
|
|
// Controls auto-hide
|
|
const resetControlsTimeout = useCallback(() => {
|
|
setShowControls(true);
|
|
if (controlsTimeoutRef.current) clearTimeout(controlsTimeoutRef.current);
|
|
controlsTimeoutRef.current = setTimeout(() => {
|
|
setShowControls(false);
|
|
}, 3000);
|
|
}, []);
|
|
|
|
// Video event handlers
|
|
useEffect(() => {
|
|
const video = videoRef.current;
|
|
if (!video) return;
|
|
|
|
const onPlay = () => setIsPlaying(true);
|
|
const onPause = () => setIsPlaying(false);
|
|
const onTimeUpdate = () => {
|
|
const range = getSeekRange(video);
|
|
setCurrentTime(video.currentTime);
|
|
setSeekWindow(range);
|
|
if (range) {
|
|
setDuration(range.duration);
|
|
setIsLive(false);
|
|
} else {
|
|
const dur = video.duration;
|
|
if (isFinite(dur) && dur > 0) {
|
|
setDuration(dur);
|
|
}
|
|
setIsLive(true);
|
|
}
|
|
};
|
|
const onDurationChange = () => {
|
|
const range = getSeekRange(video);
|
|
setSeekWindow(range);
|
|
if (range) {
|
|
setDuration(range.duration);
|
|
setIsLive(false);
|
|
} else {
|
|
const dur = video.duration;
|
|
if (isFinite(dur) && dur > 0) {
|
|
setDuration(dur);
|
|
}
|
|
}
|
|
};
|
|
const onVolumeChange = () => {
|
|
setVolume(video.volume);
|
|
setIsMuted(video.muted);
|
|
};
|
|
|
|
video.addEventListener('play', onPlay);
|
|
video.addEventListener('pause', onPause);
|
|
video.addEventListener('timeupdate', onTimeUpdate);
|
|
video.addEventListener('durationchange', onDurationChange);
|
|
video.addEventListener('volumechange', onVolumeChange);
|
|
|
|
return () => {
|
|
video.removeEventListener('play', onPlay);
|
|
video.removeEventListener('pause', onPause);
|
|
video.removeEventListener('timeupdate', onTimeUpdate);
|
|
video.removeEventListener('durationchange', onDurationChange);
|
|
video.removeEventListener('volumechange', onVolumeChange);
|
|
};
|
|
}, []);
|
|
|
|
const loadChannel = useCallback((url: string) => {
|
|
const video = videoRef.current;
|
|
if (!video) return;
|
|
|
|
setError(null);
|
|
setIsLoading(true);
|
|
setIsLive(true);
|
|
setCurrentTime(0);
|
|
setDuration(0);
|
|
setSeekWindow(null);
|
|
|
|
// Clean up previous
|
|
if (hlsRef.current) {
|
|
hlsRef.current.destroy();
|
|
hlsRef.current = null;
|
|
}
|
|
if (loadingTimeoutRef.current) {
|
|
clearTimeout(loadingTimeoutRef.current);
|
|
loadingTimeoutRef.current = undefined;
|
|
}
|
|
video.removeAttribute('src');
|
|
video.load();
|
|
|
|
const proxiedUrl = getProxiedUrl(url, channel.httpUserAgent, channel.httpReferrer);
|
|
const hasCustomHeaders = !!(channel.httpUserAgent || channel.httpReferrer);
|
|
// When custom headers are needed, skip direct attempt (browsers cannot set
|
|
// User-Agent on XHR/fetch). Always go through our proxy which can forward
|
|
// the headers server-side. This fixes audio-only issues on CCTV and similar.
|
|
const initialUrl = hasCustomHeaders ? proxiedUrl : url;
|
|
|
|
// Global loading timeout
|
|
let loadingResolved = false;
|
|
const markLoaded = () => {
|
|
if (loadingResolved) return;
|
|
loadingResolved = true;
|
|
if (loadingTimeoutRef.current) {
|
|
clearTimeout(loadingTimeoutRef.current);
|
|
loadingTimeoutRef.current = undefined;
|
|
}
|
|
setIsLoading(false);
|
|
};
|
|
const markError = (msg: string) => {
|
|
if (loadingResolved) return;
|
|
loadingResolved = true;
|
|
if (loadingTimeoutRef.current) {
|
|
clearTimeout(loadingTimeoutRef.current);
|
|
loadingTimeoutRef.current = undefined;
|
|
}
|
|
setIsLoading(false);
|
|
setError(msg);
|
|
};
|
|
|
|
loadingTimeoutRef.current = setTimeout(() => {
|
|
markError('加载超时,请尝试其他线路或频道');
|
|
}, LOADING_TIMEOUT_MS);
|
|
|
|
if (Hls.isSupported()) {
|
|
const hls = new Hls(HLS_LIVE_CONFIG);
|
|
hlsRef.current = hls;
|
|
|
|
let triedProxy = false;
|
|
let triedDirect = false;
|
|
|
|
const tryDirectVideo = (directUrl: string) => {
|
|
if (triedDirect) {
|
|
markError('播放错误,请尝试其他线路或频道');
|
|
return;
|
|
}
|
|
triedDirect = true;
|
|
const vid = videoRef.current;
|
|
if (!vid) return;
|
|
vid.src = directUrl;
|
|
vid.addEventListener('canplay', () => {
|
|
markLoaded();
|
|
vid.play().catch(() => {});
|
|
}, { once: true });
|
|
vid.addEventListener('error', () => {
|
|
if (directUrl === url) {
|
|
// Try proxied direct video
|
|
const vid2 = videoRef.current;
|
|
if (!vid2) return;
|
|
vid2.src = proxiedUrl;
|
|
vid2.addEventListener('canplay', () => {
|
|
markLoaded();
|
|
vid2.play().catch(() => {});
|
|
}, { once: true });
|
|
vid2.addEventListener('error', () => {
|
|
markError('播放错误,请尝试其他线路或频道');
|
|
}, { once: true });
|
|
} else {
|
|
markError('播放错误,请尝试其他线路或频道');
|
|
}
|
|
}, { once: true });
|
|
};
|
|
|
|
const tryWithProxy = () => {
|
|
if (triedProxy) {
|
|
tryDirectVideo(url);
|
|
return;
|
|
}
|
|
triedProxy = true;
|
|
hls.destroy();
|
|
const hlsProxy = new Hls(HLS_LIVE_CONFIG);
|
|
hlsRef.current = hlsProxy;
|
|
hlsProxy.loadSource(proxiedUrl);
|
|
hlsProxy.attachMedia(video);
|
|
|
|
// Filter HEVC levels for proxy attempt too
|
|
hlsProxy.on(Hls.Events.MANIFEST_PARSED, () => {
|
|
filterHEVCLevels(hlsProxy);
|
|
markLoaded();
|
|
video.play().catch(() => {});
|
|
});
|
|
hlsProxy.on(Hls.Events.ERROR, (_, data) => {
|
|
if (data.fatal) {
|
|
if (data.type === Hls.ErrorTypes.MEDIA_ERROR) {
|
|
hlsProxy.recoverMediaError();
|
|
} else {
|
|
hlsProxy.destroy();
|
|
hlsRef.current = null;
|
|
tryDirectVideo(url);
|
|
}
|
|
}
|
|
});
|
|
};
|
|
|
|
// Helper: Filter out HEVC levels that browser may not support (fixes audio-only issue)
|
|
const filterHEVCLevels = (hlsInstance: Hls) => {
|
|
if (!hlsInstance.levels || hlsInstance.levels.length <= 1) return;
|
|
const h264Levels = hlsInstance.levels
|
|
.map((level, index) => ({ level, index }))
|
|
.filter(({ level }) => {
|
|
const codec = level.videoCodec?.toLowerCase() || '';
|
|
// Keep levels without HEVC codec (H.264 or unknown)
|
|
return !codec.includes('hev') && !codec.includes('h265') && !codec.includes('hvc');
|
|
});
|
|
// If we have H.264 levels, restrict to those
|
|
if (h264Levels.length > 0 && h264Levels.length < hlsInstance.levels.length) {
|
|
console.info('[IPTV] Filtering HEVC levels, using H.264 only for compatibility');
|
|
// Set level to first H.264 level
|
|
hlsInstance.currentLevel = h264Levels[0].index;
|
|
}
|
|
};
|
|
|
|
// First try initial URL (direct or proxied based on custom headers)
|
|
hls.loadSource(initialUrl);
|
|
hls.attachMedia(video);
|
|
hls.on(Hls.Events.MANIFEST_PARSED, () => {
|
|
// Filter HEVC levels to prevent audio-only playback
|
|
filterHEVCLevels(hls);
|
|
markLoaded();
|
|
video.play().catch(() => {});
|
|
});
|
|
hls.on(Hls.Events.ERROR, (_, data) => {
|
|
if (data.fatal) {
|
|
if (data.type === Hls.ErrorTypes.NETWORK_ERROR) {
|
|
tryWithProxy();
|
|
} else if (data.type === Hls.ErrorTypes.MEDIA_ERROR) {
|
|
hls.recoverMediaError();
|
|
} else {
|
|
tryWithProxy();
|
|
}
|
|
}
|
|
});
|
|
} else if (video.canPlayType('application/vnd.apple.mpegurl')) {
|
|
// Native HLS (Safari/iOS)
|
|
video.src = initialUrl;
|
|
video.addEventListener('canplay', () => {
|
|
markLoaded();
|
|
video.play().catch(() => {});
|
|
}, { once: true });
|
|
video.addEventListener('error', () => {
|
|
// If direct failed, try proxy; if already proxied, fail
|
|
if (initialUrl === proxiedUrl) {
|
|
markError('播放错误');
|
|
return;
|
|
}
|
|
video.src = proxiedUrl;
|
|
video.addEventListener('canplay', () => {
|
|
markLoaded();
|
|
video.play().catch(() => {});
|
|
}, { once: true });
|
|
video.addEventListener('error', () => {
|
|
markError('播放错误');
|
|
}, { once: true });
|
|
}, { once: true });
|
|
} else {
|
|
// Direct video fallback
|
|
video.src = initialUrl;
|
|
video.addEventListener('canplay', () => {
|
|
markLoaded();
|
|
video.play().catch(() => {});
|
|
}, { once: true });
|
|
video.addEventListener('error', () => {
|
|
if (initialUrl === proxiedUrl) {
|
|
markError('播放错误,请尝试其他频道');
|
|
return;
|
|
}
|
|
video.src = proxiedUrl;
|
|
video.addEventListener('canplay', () => {
|
|
markLoaded();
|
|
video.play().catch(() => {});
|
|
}, { once: true });
|
|
video.addEventListener('error', () => {
|
|
markError('播放错误,请尝试其他频道');
|
|
}, { once: true });
|
|
}, { once: true });
|
|
}
|
|
}, [channel.httpUserAgent, channel.httpReferrer]);
|
|
|
|
// Load on channel/route change
|
|
useEffect(() => {
|
|
loadChannel(currentUrl);
|
|
return () => {
|
|
if (hlsRef.current) {
|
|
hlsRef.current.destroy();
|
|
hlsRef.current = null;
|
|
}
|
|
if (loadingTimeoutRef.current) {
|
|
clearTimeout(loadingTimeoutRef.current);
|
|
loadingTimeoutRef.current = undefined;
|
|
}
|
|
};
|
|
}, [currentUrl, loadChannel]);
|
|
|
|
// Reset route index when channel changes
|
|
useEffect(() => {
|
|
setCurrentRouteIndex(0);
|
|
setShowAllRoutes(false);
|
|
}, [channel.name, channel.url]);
|
|
|
|
// Playback controls
|
|
const togglePlay = () => {
|
|
const video = videoRef.current;
|
|
if (!video) return;
|
|
if (video.paused) video.play().catch(() => {});
|
|
else video.pause();
|
|
};
|
|
|
|
const toggleMute = () => {
|
|
const video = videoRef.current;
|
|
if (!video) return;
|
|
video.muted = !video.muted;
|
|
};
|
|
|
|
const handleVolumeChange = (value: number) => {
|
|
const video = videoRef.current;
|
|
if (!video) return;
|
|
video.volume = value;
|
|
if (value > 0 && video.muted) video.muted = false;
|
|
};
|
|
|
|
const progressRef = useRef<HTMLDivElement>(null);
|
|
|
|
const handleSeek = (e: React.MouseEvent<HTMLDivElement>) => {
|
|
if (isLive) return;
|
|
const video = videoRef.current;
|
|
const bar = progressRef.current;
|
|
if (!video || !bar) return;
|
|
const seekRange = getSeekRange(video);
|
|
if (!seekRange) return;
|
|
const rect = bar.getBoundingClientRect();
|
|
const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
|
|
video.currentTime = seekRange.start + ratio * seekRange.duration;
|
|
};
|
|
|
|
const progressPercent = useMemo(() => {
|
|
if (seekWindow) {
|
|
return Math.max(0, Math.min(100, ((currentTime - seekWindow.start) / seekWindow.duration) * 100));
|
|
}
|
|
if (!duration) return 0;
|
|
return Math.max(0, Math.min(100, (currentTime / duration) * 100));
|
|
}, [currentTime, duration, seekWindow]);
|
|
|
|
const shouldHideCursor = shouldHidePlayerCursor({
|
|
isFullscreen,
|
|
isPlaying,
|
|
showControls,
|
|
hasInteractiveOverlay: showSidebar || showVolumeSlider || Boolean(error),
|
|
});
|
|
|
|
const toggleFullscreen = async () => {
|
|
if (!containerRef.current) return;
|
|
if (document.fullscreenElement) {
|
|
await document.exitFullscreen();
|
|
} else {
|
|
await containerRef.current.requestFullscreen();
|
|
}
|
|
};
|
|
|
|
// Keyboard shortcuts (matching main video player)
|
|
useEffect(() => {
|
|
const handleKeyDown = (e: KeyboardEvent) => {
|
|
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return;
|
|
|
|
resetControlsTimeout();
|
|
const video = videoRef.current;
|
|
if (!video) return;
|
|
|
|
switch (e.key.toLowerCase()) {
|
|
case ' ':
|
|
case 'k':
|
|
e.preventDefault();
|
|
togglePlay();
|
|
break;
|
|
case 'f':
|
|
e.preventDefault();
|
|
toggleFullscreen();
|
|
break;
|
|
case 'm':
|
|
e.preventDefault();
|
|
toggleMute();
|
|
break;
|
|
case 'escape':
|
|
e.preventDefault();
|
|
onClose();
|
|
break;
|
|
case 'arrowright':
|
|
case 'l':
|
|
e.preventDefault();
|
|
if (!isLive && isFinite(video.duration)) {
|
|
video.currentTime = Math.min(video.duration, video.currentTime + seekStepSeconds);
|
|
}
|
|
break;
|
|
case 'arrowleft':
|
|
case 'j':
|
|
e.preventDefault();
|
|
if (!isLive && isFinite(video.duration)) {
|
|
video.currentTime = Math.max(0, video.currentTime - seekStepSeconds);
|
|
}
|
|
break;
|
|
case 'arrowup':
|
|
e.preventDefault();
|
|
video.volume = Math.min(1, video.volume + 0.1);
|
|
if (video.muted) video.muted = false;
|
|
break;
|
|
case 'arrowdown':
|
|
e.preventDefault();
|
|
video.volume = Math.max(0, video.volume - 0.1);
|
|
break;
|
|
}
|
|
};
|
|
|
|
window.addEventListener('keydown', handleKeyDown);
|
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
|
});
|
|
|
|
const VolumeIcon = isMuted || volume === 0 ? Icons.VolumeX : volume < 0.5 ? Icons.Volume1 : Icons.Volume2;
|
|
|
|
// Debounce search with useTransition for non-blocking rendering
|
|
useEffect(() => {
|
|
const timer = setTimeout(() => {
|
|
const q = sidebarSearch.toLowerCase().trim();
|
|
if (!q) {
|
|
setFilteredResults([]);
|
|
setSidebarVisibleCount(50);
|
|
return;
|
|
}
|
|
startSearchTransition(() => {
|
|
const results = channels.filter(ch => ch.name.toLowerCase().includes(q));
|
|
setFilteredResults(results);
|
|
setSidebarVisibleCount(50);
|
|
});
|
|
}, 200);
|
|
return () => clearTimeout(timer);
|
|
}, [sidebarSearch, channels]);
|
|
|
|
const isSearchMode = sidebarSearch.trim().length > 0;
|
|
|
|
// Toggle source expansion
|
|
const toggleSource = useCallback((sourceId: string) => {
|
|
setExpandedSources(prev => {
|
|
const next = new Set(prev);
|
|
if (next.has(sourceId)) next.delete(sourceId);
|
|
else next.add(sourceId);
|
|
return next;
|
|
});
|
|
}, []);
|
|
|
|
// Toggle group expansion
|
|
const toggleGroup = useCallback((key: string) => {
|
|
setExpandedGroups(prev => {
|
|
const next = new Set(prev);
|
|
if (next.has(key)) next.delete(key);
|
|
else next.add(key);
|
|
return next;
|
|
});
|
|
}, []);
|
|
|
|
const toggleActiveSource = useCallback(() => {
|
|
if (!activeSourceId) return;
|
|
toggleSource(activeSourceId);
|
|
}, [activeSourceId, toggleSource]);
|
|
|
|
const toggleActiveGroup = useCallback(() => {
|
|
if (!activeSourceId || !channel.group) return;
|
|
setExpandedSources(prev => new Set(prev).add(activeSourceId));
|
|
toggleGroup(`${activeSourceId}::${channel.group}`);
|
|
}, [activeSourceId, channel.group, toggleGroup]);
|
|
|
|
// Render a channel button
|
|
const renderChannelButton = (ch: M3UChannel, i: number) => {
|
|
const isActive = ch.name === channel.name && ch.url === channel.url;
|
|
return (
|
|
<button
|
|
key={`${ch.sourceId || ''}-${ch.name}-${i}`}
|
|
ref={isActive ? activeChannelRef : undefined}
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
onChannelChange(ch);
|
|
}}
|
|
className={`w-full text-left px-3 py-2 rounded-lg text-sm transition-colors cursor-pointer ${
|
|
isActive
|
|
? 'bg-[var(--accent-color)] text-white'
|
|
: 'text-white/70 hover:bg-white/10 hover:text-white'
|
|
}`}
|
|
>
|
|
<div className="flex items-center gap-2">
|
|
{isActive && (
|
|
<span className="w-1.5 h-1.5 rounded-full bg-white flex-shrink-0 animate-pulse" />
|
|
)}
|
|
<span className="truncate flex-1">{ch.name}</span>
|
|
{ch.routes && ch.routes.length > 1 && (
|
|
<span className={`text-[10px] px-1.5 py-0.5 rounded flex-shrink-0 ${
|
|
isActive ? 'bg-white/20' : 'bg-white/5 text-white/40'
|
|
}`}>
|
|
{ch.routes.length}线路
|
|
</span>
|
|
)}
|
|
</div>
|
|
</button>
|
|
);
|
|
};
|
|
|
|
// Render multi-level sidebar content
|
|
const renderMultiLevelSidebar = () => {
|
|
if (!channelsBySource || !sources) return null;
|
|
const orderedSources = activeSourceId
|
|
? [
|
|
...sources.filter((source) => source.id === activeSourceId),
|
|
...sources.filter((source) => source.id !== activeSourceId),
|
|
]
|
|
: sources;
|
|
|
|
return (
|
|
<div className="p-1">
|
|
{orderedSources.map(source => {
|
|
const sourceData = channelsBySource[source.id];
|
|
if (!sourceData || sourceData.channels.length === 0) return null;
|
|
|
|
const isExpanded = expandedSources.has(source.id);
|
|
const isActiveSource = source.id === activeSourceId;
|
|
const orderedGroups = isActiveSource && channel.group
|
|
? [channel.group, ...sourceData.groups.filter((group) => group !== channel.group)]
|
|
: sourceData.groups;
|
|
|
|
return (
|
|
<div key={source.id} className="mb-1">
|
|
{/* Source Header */}
|
|
<button
|
|
onClick={(e) => { e.stopPropagation(); toggleSource(source.id); }}
|
|
className={`w-full flex items-center justify-between px-3 py-2 rounded-lg text-sm font-medium transition-colors cursor-pointer ${
|
|
isActiveSource
|
|
? 'bg-white/10 text-white'
|
|
: 'text-white/90 hover:bg-white/10'
|
|
}`}
|
|
>
|
|
<div className="flex items-center gap-2 min-w-0">
|
|
<Icons.TV size={14} className="flex-shrink-0 text-[var(--accent-color)]" />
|
|
<span className="truncate">{source.name}</span>
|
|
<span className="text-[10px] text-white/40 flex-shrink-0">{sourceData.channels.length}</span>
|
|
</div>
|
|
<Icons.ChevronDown
|
|
size={14}
|
|
className={`flex-shrink-0 text-white/40 transition-transform duration-200 ${isExpanded ? 'rotate-180' : ''}`}
|
|
/>
|
|
</button>
|
|
|
|
{/* Source Content */}
|
|
{isExpanded && (
|
|
<div className="ml-2 border-l border-white/10 pl-1">
|
|
{orderedGroups.length > 0 ? (
|
|
// Has groups — show group-level
|
|
orderedGroups.map(group => {
|
|
const groupKey = `${source.id}::${group}`;
|
|
const groupExpanded = expandedGroups.has(groupKey);
|
|
const groupChannels = sourceData.channels.filter(ch => ch.group === group);
|
|
const isActiveGroup = groupKey === activeGroupKey;
|
|
|
|
return (
|
|
<div key={groupKey} className="mb-0.5">
|
|
<button
|
|
onClick={(e) => { e.stopPropagation(); toggleGroup(groupKey); }}
|
|
className={`w-full flex items-center justify-between px-2 py-1.5 rounded text-xs transition-colors cursor-pointer ${
|
|
isActiveGroup
|
|
? 'bg-white/10 text-white'
|
|
: 'text-white/60 hover:bg-white/5'
|
|
}`}
|
|
>
|
|
<div className="flex items-center gap-1.5 min-w-0">
|
|
<Icons.Tag size={12} className="flex-shrink-0" />
|
|
<span className="truncate">{group}</span>
|
|
<span className="text-[10px] text-white/30 flex-shrink-0">{groupChannels.length}</span>
|
|
</div>
|
|
<Icons.ChevronDown
|
|
size={12}
|
|
className={`flex-shrink-0 text-white/30 transition-transform duration-200 ${groupExpanded ? 'rotate-180' : ''}`}
|
|
/>
|
|
</button>
|
|
{groupExpanded && (
|
|
<div className="ml-2">
|
|
{groupChannels.map((ch, i) => renderChannelButton(ch, i))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
})
|
|
) : (
|
|
// No groups — show channels directly
|
|
sourceData.channels.map((ch, i) => renderChannelButton(ch, i))
|
|
)}
|
|
|
|
{/* Ungrouped channels */}
|
|
{sourceData.groups.length > 0 && (() => {
|
|
const ungrouped = sourceData.channels.filter(ch => !ch.group);
|
|
if (ungrouped.length === 0) return null;
|
|
return (
|
|
<div className="mb-0.5">
|
|
<div className="px-2 py-1 text-[10px] text-white/30">未分组</div>
|
|
{ungrouped.map((ch, i) => renderChannelButton(ch, i))}
|
|
</div>
|
|
);
|
|
})()}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// Render flat channel list (search results or single-source fallback)
|
|
const renderFlatChannelList = (channelList: M3UChannel[]) => {
|
|
const visible = channelList.slice(0, sidebarVisibleCount);
|
|
return (
|
|
<div className="p-1">
|
|
{visible.map((ch, i) => renderChannelButton(ch, i))}
|
|
{channelList.length > sidebarVisibleCount && (
|
|
<button
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
setSidebarVisibleCount(prev => prev + 50);
|
|
}}
|
|
className="w-full py-2 text-xs text-white/50 hover:text-white/80 transition-colors cursor-pointer"
|
|
>
|
|
显示更多 ({channelList.length - sidebarVisibleCount} 个频道)
|
|
</button>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
return (
|
|
<div
|
|
ref={containerRef}
|
|
className="fixed inset-0 z-[9999] bg-black flex"
|
|
style={{ cursor: shouldHideCursor ? 'none' : undefined }}
|
|
onMouseMove={resetControlsTimeout}
|
|
onClick={(e) => {
|
|
if ((e.target as HTMLElement).closest('[data-controls]') || (e.target as HTMLElement).closest('[data-sidebar]')) return;
|
|
togglePlay();
|
|
resetControlsTimeout();
|
|
}}
|
|
>
|
|
{/* Player Area */}
|
|
<div className="flex-1 relative overflow-hidden">
|
|
<video
|
|
ref={videoRef}
|
|
className="w-full h-full object-contain bg-black"
|
|
playsInline
|
|
autoPlay
|
|
/>
|
|
|
|
{/* Loading */}
|
|
{isLoading && (
|
|
<div className="absolute inset-0 flex items-center justify-center bg-black/50 pointer-events-none">
|
|
<div className="flex flex-col items-center gap-3">
|
|
<div className="w-10 h-10 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
|
<p className="text-white/70 text-sm">加载中...</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Error */}
|
|
{error && (
|
|
<div className="absolute inset-0 flex items-center justify-center bg-black/80">
|
|
<div className="text-center" data-controls>
|
|
<p className="text-red-400 text-sm mb-3">{error}</p>
|
|
<div className="flex gap-2 flex-wrap justify-center">
|
|
<button
|
|
onClick={(e) => { e.stopPropagation(); loadChannel(currentUrl); }}
|
|
className="px-4 py-2 bg-white/10 hover:bg-white/20 rounded-lg text-white text-sm transition-colors cursor-pointer"
|
|
>
|
|
重试
|
|
</button>
|
|
{routes.length > 1 && currentRouteIndex < routes.length - 1 && (
|
|
<button
|
|
onClick={(e) => { e.stopPropagation(); setCurrentRouteIndex(prev => prev + 1); }}
|
|
className="px-4 py-2 bg-blue-600/80 hover:bg-blue-600 rounded-lg text-white text-sm transition-colors cursor-pointer"
|
|
>
|
|
切换线路
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Top Bar */}
|
|
<div
|
|
data-controls
|
|
className={`absolute top-0 left-0 right-0 p-4 bg-gradient-to-b from-black/70 to-transparent transition-opacity duration-300 ${showControls ? 'opacity-100' : 'opacity-0 pointer-events-none'}`}
|
|
>
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-3">
|
|
{isLive && (
|
|
<span className="px-2 py-0.5 bg-red-600 text-white text-xs font-bold rounded flex items-center gap-1">
|
|
<span className="w-1.5 h-1.5 rounded-full bg-white animate-pulse" />
|
|
LIVE
|
|
</span>
|
|
)}
|
|
<span className="text-white text-sm font-medium drop-shadow-lg">{channel.name}</span>
|
|
{routes.length > 1 && (
|
|
<span className="text-white/50 text-xs">线路 {currentRouteIndex + 1}/{routes.length}</span>
|
|
)}
|
|
</div>
|
|
<button
|
|
onClick={(e) => { e.stopPropagation(); onClose(); }}
|
|
className="w-9 h-9 flex items-center justify-center rounded-full bg-black/40 hover:bg-black/60 text-white transition-colors cursor-pointer"
|
|
>
|
|
<Icons.X size={18} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Bottom Controls */}
|
|
<div
|
|
data-controls
|
|
className={`absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/80 to-transparent transition-opacity duration-300 ${showControls ? 'opacity-100' : 'opacity-0 pointer-events-none'}`}
|
|
>
|
|
{/* Progress Bar (non-live only) */}
|
|
{!isLive && duration > 0 && (
|
|
<div className="px-4 pt-2">
|
|
<div
|
|
ref={progressRef}
|
|
className="group h-1 hover:h-2 bg-white/20 rounded-full cursor-pointer transition-all relative"
|
|
onClick={(e) => { e.stopPropagation(); handleSeek(e); }}
|
|
>
|
|
<div
|
|
className="h-full bg-[var(--accent-color)] rounded-full relative pointer-events-none"
|
|
style={{ width: `${progressPercent}%` }}
|
|
>
|
|
<div className="absolute right-0 top-1/2 -translate-y-1/2 w-3 h-3 bg-white rounded-full opacity-0 group-hover:opacity-100 transition-opacity" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex items-center gap-3 px-4 py-3">
|
|
{/* Play/Pause */}
|
|
<button
|
|
onClick={(e) => { e.stopPropagation(); togglePlay(); }}
|
|
className="w-9 h-9 flex items-center justify-center rounded-full hover:bg-white/10 text-white transition-colors cursor-pointer"
|
|
>
|
|
{isPlaying ? <Icons.Pause size={20} /> : <Icons.Play size={20} />}
|
|
</button>
|
|
|
|
{/* Time Display */}
|
|
{!isLive && duration > 0 && (
|
|
<span className="text-white/70 text-xs tabular-nums">
|
|
{formatTime(currentTime)} / {formatTime(duration)}
|
|
</span>
|
|
)}
|
|
|
|
<div className="flex-1" />
|
|
|
|
{/* Route Selector - collapsed */}
|
|
{routes.length > 1 && (
|
|
<div className="flex gap-1 items-center" data-controls>
|
|
{visibleRoutes.map((_, i) => (
|
|
<button
|
|
key={i}
|
|
onClick={(e) => { e.stopPropagation(); setCurrentRouteIndex(i); }}
|
|
className={`px-2 py-0.5 text-[10px] rounded transition-colors cursor-pointer ${
|
|
i === currentRouteIndex
|
|
? 'bg-[var(--accent-color)] text-white'
|
|
: 'bg-white/10 text-white/60 hover:bg-white/20'
|
|
}`}
|
|
>
|
|
线路{i + 1}
|
|
</button>
|
|
))}
|
|
{hasMoreRoutes && (
|
|
<button
|
|
onClick={(e) => { e.stopPropagation(); setShowAllRoutes(!showAllRoutes); }}
|
|
className="px-2 py-0.5 text-[10px] rounded bg-white/5 text-white/40 hover:bg-white/10 hover:text-white/60 transition-colors cursor-pointer"
|
|
>
|
|
{showAllRoutes ? '收起' : `+${routes.length - MAX_VISIBLE_ROUTES}`}
|
|
</button>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Volume */}
|
|
<div
|
|
className="relative flex items-center"
|
|
onMouseEnter={() => setShowVolumeSlider(true)}
|
|
onMouseLeave={() => setShowVolumeSlider(false)}
|
|
>
|
|
<button
|
|
onClick={(e) => { e.stopPropagation(); toggleMute(); }}
|
|
className="w-9 h-9 flex items-center justify-center rounded-full hover:bg-white/10 text-white transition-colors cursor-pointer"
|
|
>
|
|
<VolumeIcon size={18} />
|
|
</button>
|
|
{showVolumeSlider && (
|
|
<div className="ml-1 w-20 flex items-center" data-controls>
|
|
<input
|
|
type="range"
|
|
min="0"
|
|
max="1"
|
|
step="0.05"
|
|
value={isMuted ? 0 : volume}
|
|
onChange={(e) => { e.stopPropagation(); handleVolumeChange(parseFloat(e.target.value)); }}
|
|
onClick={(e) => e.stopPropagation()}
|
|
className="w-full h-1 accent-white cursor-pointer"
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Channel List */}
|
|
<button
|
|
onClick={(e) => { e.stopPropagation(); setShowSidebar(!showSidebar); }}
|
|
className="w-9 h-9 flex items-center justify-center rounded-full hover:bg-white/10 text-white transition-colors cursor-pointer"
|
|
>
|
|
<Icons.List size={18} />
|
|
</button>
|
|
|
|
{/* Fullscreen */}
|
|
<button
|
|
onClick={(e) => { e.stopPropagation(); toggleFullscreen(); }}
|
|
className="w-9 h-9 flex items-center justify-center rounded-full hover:bg-white/10 text-white transition-colors cursor-pointer"
|
|
>
|
|
{isFullscreen ? <Icons.Minimize size={18} /> : <Icons.Maximize size={18} />}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Sidebar */}
|
|
{showSidebar && (
|
|
<div data-sidebar className="w-72 bg-[#111] border-l border-white/10 overflow-y-auto flex-shrink-0">
|
|
<div className="sticky top-0 bg-[#111] z-10">
|
|
<div className="p-3 border-b border-white/10 flex items-center justify-between">
|
|
<h3 className="text-white text-sm font-medium">频道列表</h3>
|
|
<button
|
|
onClick={(e) => { e.stopPropagation(); setShowSidebar(false); }}
|
|
className="text-white/50 hover:text-white cursor-pointer"
|
|
>
|
|
<Icons.X size={16} />
|
|
</button>
|
|
</div>
|
|
<div className="px-3 py-2 border-b border-white/10">
|
|
<div className="relative">
|
|
<Icons.Search size={12} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-white/30" />
|
|
<input
|
|
type="text"
|
|
placeholder="搜索频道..."
|
|
value={sidebarSearch}
|
|
onChange={(e) => setSidebarSearch(e.target.value)}
|
|
onClick={(e) => e.stopPropagation()}
|
|
className="w-full pl-7 pr-2 py-1.5 bg-white/5 border border-white/10 rounded-lg text-xs text-white placeholder:text-white/30 focus:outline-none focus:border-white/20"
|
|
/>
|
|
{isSearching && (
|
|
<div className="absolute right-2.5 top-1/2 -translate-y-1/2">
|
|
<div className="w-3 h-3 border border-white/30 border-t-white rounded-full animate-spin" />
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
{(activeSource || channel.group) && (
|
|
<div className="px-3 py-2 border-b border-white/10">
|
|
<div className="flex items-center gap-1.5 flex-wrap">
|
|
<span className="text-[10px] uppercase tracking-[0.18em] text-white/30">当前</span>
|
|
{activeSource && (
|
|
<button
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
toggleActiveSource();
|
|
}}
|
|
className={`px-2 py-1 rounded-full text-[11px] border transition-colors cursor-pointer ${
|
|
activeSourceId && expandedSources.has(activeSourceId)
|
|
? 'bg-[var(--accent-color)] border-[var(--accent-color)] text-white'
|
|
: 'bg-white/5 border-white/10 text-white/80 hover:bg-white/10'
|
|
}`}
|
|
>
|
|
源: {activeSource.name}
|
|
</button>
|
|
)}
|
|
{channel.group && activeGroupKey && (
|
|
<button
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
toggleActiveGroup();
|
|
}}
|
|
className={`px-2 py-1 rounded-full text-[11px] border transition-colors cursor-pointer ${
|
|
expandedGroups.has(activeGroupKey)
|
|
? 'bg-[var(--accent-color)] border-[var(--accent-color)] text-white'
|
|
: 'bg-white/5 border-white/10 text-white/80 hover:bg-white/10'
|
|
}`}
|
|
>
|
|
标签: {channel.group}
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Sidebar Content */}
|
|
{isSearchMode ? (
|
|
// Search mode — flat list of filtered results
|
|
renderFlatChannelList(filteredResults)
|
|
) : hasMultiSource ? (
|
|
// Multi-source mode — hierarchical list
|
|
renderMultiLevelSidebar()
|
|
) : (
|
|
// Single source or fallback — flat list
|
|
renderFlatChannelList(channels)
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|