diff --git a/README.md b/README.md index c1c59cc..648f2e3 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,7 @@ - **搜索结果显示**:支持默认显示和合并同名源两种模式 - **实时延迟监测**:可选实时显示各源的网络延迟 - **清晰度标签**:自动解析并显示视频清晰度(4K/蓝光/1080P/720P/HD 等),方便快速分辨源质量 +- **实际分辨率检测**:播放视频时自动检测并显示实际视频分辨率(如 1920x1080),不依赖源标签,显示真实清晰度 - **繁体中文搜索**:自动将繁体中文转换为简体中文进行搜索,确保繁体输入也能搜到结果 - **源过滤**:支持按源和类型筛选搜索结果,源标签支持按类型分组显示,智能合并同名分类标签,展开/折叠状态持久化记忆 - **多级标签**:搜索结果和播放器中显示源名称和内容类型双重标签 @@ -70,11 +71,13 @@ - **延迟排序**:线路按网络延迟自动排序,最快的源排在前面 - **源切换**:在线路列表中快速切换到其他源,支持断点续播 - **自动切源**:当当前源不可用时,自动切换到延迟最低的可用源 +- **短链接优化**:使用 sessionStorage 缓存源数据,避免 URL 过长导致 CDN 414 错误 ### IPTV 直播 - **M3U 播放列表**:支持导入和管理 M3U/M3U8 格式的 IPTV 源 - **JSON 频道列表**:支持导入 JSON 格式的频道列表(数组或对象格式,自动识别) +- **HEVC 智能兼容**:自动检测 HEVC/H.265 编码流,优先选择 H.264 级别以避免音画不同步或仅有声音问题 - **频道网格**:按分组展示频道,支持分页浏览,大列表搜索优化 - **多级频道列表**:播放器内按源分组 → 按分类分组 → 频道的三级列表导航 - **多线路折叠**:频道多线路默认显示前 3 条,可点击展开查看全部 @@ -848,6 +851,20 @@ Android 7.0 (API 24) 的 WebView 基于 Chrome 51,不支持本项目使用的 KVideo 已内置代理服务器自动处理 CORS 问题和 HLS URL 重写,大部分 HLS 直播流应能正常播放。 +### IPTV CCTV 等频道只有声音没有画面 + +部分 CCTV 和卫视频道使用 HEVC (H.265) 编码,某些浏览器不支持硬件解码 HEVC。KVideo v4.5.0+ 已自动检测 HEVC 流并优先选择 H.264 级别以提高兼容性。如果问题仍存在,建议使用 Chrome 或 Edge 浏览器。 + +### 部分浏览器无法播放视频 + +一些内置浏览器(如 vivo 浏览器、QQ 浏览器等)的 WebView 可能不完整支持 MSE (Media Source Extensions) 和 HLS.js。建议使用以下浏览器: +- Chrome(推荐) +- Edge +- Safari(iOS/macOS) +- Firefox + +KVideo v4.5.0+ 已增加多级回退机制,会依次尝试 HLS.js、原生 HLS、代理播放等方式。 + ## 贡献代码 我们非常欢迎各种形式的贡献!无论是报告 Bug、提出新功能建议、改进文档,还是提交代码,你的每一份贡献都让这个项目变得更好。 diff --git a/app/player/page.tsx b/app/player/page.tsx index c018206..9f71a6e 100644 --- a/app/player/page.tsx +++ b/app/player/page.tsx @@ -18,6 +18,7 @@ import { settingsStore } from '@/lib/store/settings-store'; import { premiumModeSettingsStore } from '@/lib/store/premium-mode-settings'; import { SegmentedControl } from '@/components/ui/SegmentedControl'; import { getSourceName } from '@/lib/utils/source-names'; +import { retrieveGroupedSources, storeGroupedSources } from '@/lib/utils/grouped-sources-cache'; function PlayerContent() { const searchParams = useSearchParams(); @@ -29,7 +30,9 @@ function PlayerContent() { const source = searchParams.get('source'); const title = searchParams.get('title'); const episodeParam = searchParams.get('episode'); + // Support both legacy 'groupedSources' (full JSON) and new 'gs' (sessionStorage key) const groupedSourcesParam = searchParams.get('groupedSources'); + const gsKey = searchParams.get('gs'); // Track settings - use mode-specific store const modeStore = isPremium ? premiumModeSettingsStore : settingsStore; @@ -45,6 +48,24 @@ function PlayerContent() { setIsReversed(modeStore.getSettings().episodeReverseOrder); }, []); + // Migrate legacy long groupedSources URL to short gs key + useEffect(() => { + if (groupedSourcesParam && !gsKey) { + try { + const data = JSON.parse(groupedSourcesParam); + if (Array.isArray(data) && data.length > 0) { + const newKey = storeGroupedSources(data); + if (newKey) { + const params = new URLSearchParams(searchParams.toString()); + params.delete('groupedSources'); + params.set('gs', newKey); + router.replace(`/player?${params.toString()}`, { scroll: false }); + } + } + } catch { /* ignore parse errors */ } + } + }, []); // Run once on mount + // Redirect if no video ID or source if (!videoId || !source) { router.push('/'); @@ -74,7 +95,12 @@ function PlayerContent() { const groupedSources = useMemo(() => { let sources: SourceInfo[] = []; - if (groupedSourcesParam) { + + // Try sessionStorage cache first (new short URL), then fall back to URL param (legacy) + if (gsKey) { + const cached = retrieveGroupedSources(gsKey); + if (cached) sources = cached; + } else if (groupedSourcesParam) { try { sources = JSON.parse(groupedSourcesParam); } catch { @@ -108,7 +134,7 @@ function PlayerContent() { } return sources; - }, [groupedSourcesParam, source, videoId, videoData?.vod_pic, discoveredSources]); + }, [gsKey, groupedSourcesParam, source, videoId, videoData?.vod_pic, discoveredSources]); // Wire up the source unavailable handler now that groupedSources is defined sourceUnavailableRef.current = () => { @@ -131,7 +157,13 @@ function PlayerContent() { params.set('source', best.source); params.set('title', title || ''); if (episodeParam) params.set('episode', episodeParam); - if (groupedSourcesParam) params.set('groupedSources', groupedSourcesParam); + // Use short gs key for grouped sources + if (gsKey) { + params.set('gs', gsKey); + } else if (groupedSources.length > 1) { + const newKey = storeGroupedSources(groupedSources); + if (newKey) params.set('gs', newKey); + } if (isPremium) params.set('premium', '1'); router.replace(`/player?${params.toString()}`, { scroll: false }); }; @@ -150,7 +182,10 @@ function PlayerContent() { // Check if existing grouped sources already have full info (pic + latency) let existingSources: SourceInfo[] = []; - if (groupedSourcesParam) { + if (gsKey) { + const cached = retrieveGroupedSources(gsKey); + if (cached) existingSources = cached; + } else if (groupedSourcesParam) { try { existingSources = JSON.parse(groupedSourcesParam); } catch {} } // Always fetch alternatives if there's a pending fallback (source unavailable) @@ -224,7 +259,7 @@ function PlayerContent() { })(); return () => controller.abort(); - }, [title, source, groupedSourcesParam, isPremium]); + }, [title, source, gsKey, groupedSourcesParam, isPremium]); // Track current source for switching const [currentSourceId, setCurrentSourceId] = useState(source); @@ -401,12 +436,13 @@ function PlayerContent() { if (playerTimeRef.current > 1) { params.set('t', Math.floor(playerTimeRef.current).toString()); } - // Pass all known sources so switching persists + // Store all known sources using short gs key const allSources = groupedSources.length > 0 ? groupedSources : []; if (allSources.length > 1) { - params.set('groupedSources', JSON.stringify(allSources)); - } else if (groupedSourcesParam) { - params.set('groupedSources', groupedSourcesParam); + const newKey = storeGroupedSources(allSources); + if (newKey) params.set('gs', newKey); + } else if (gsKey) { + params.set('gs', gsKey); } if (isPremium) { params.set('premium', '1'); diff --git a/components/history/HistoryItem.tsx b/components/history/HistoryItem.tsx index 0acc154..b411bbc 100644 --- a/components/history/HistoryItem.tsx +++ b/components/history/HistoryItem.tsx @@ -9,6 +9,7 @@ import { formatTime, formatDate } from '@/lib/utils/format-utils'; import { PosterImage } from './PosterImage'; import { FavoriteButton } from '@/components/favorites/FavoriteButton'; import { getSourceName } from '@/lib/utils/source-names'; +import { storeGroupedSources } from '@/lib/utils/grouped-sources-cache'; import type { VideoHistoryItem } from '@/lib/types'; interface HistoryItemProps { @@ -25,14 +26,17 @@ export function HistoryItem({ item, onRemove, isPremium = false }: HistoryItemPr title: item.title, episode: item.episodeIndex.toString(), }); - // Pass sourceMap as groupedSources for source switching + // Store sourceMap in sessionStorage to avoid long URLs if (item.sourceMap && Object.keys(item.sourceMap).length > 1) { const groupData = Object.entries(item.sourceMap).map(([sourceName, videoId]) => ({ id: videoId, source: sourceName, sourceName: getSourceName(sourceName), })); - params.set('groupedSources', JSON.stringify(groupData)); + const cacheKey = storeGroupedSources(groupData); + if (cacheKey) { + params.set('gs', cacheKey); + } } if (isPremium) { params.set('premium', '1'); diff --git a/components/history/WatchHistorySidebar.tsx b/components/history/WatchHistorySidebar.tsx index 51262c2..4d6bf6e 100644 --- a/components/history/WatchHistorySidebar.tsx +++ b/components/history/WatchHistorySidebar.tsx @@ -114,6 +114,7 @@ export function WatchHistorySidebar({ isPremium = false }: { isPremium?: boolean = { 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; @@ -275,7 +277,10 @@ export function IPTVPlayer({ channel, onClose, channels, onChannelChange, channe hlsRef.current = hlsProxy; hlsProxy.loadSource(proxiedUrl); hlsProxy.attachMedia(video); - hlsProxy.on(Hls.Events.MANIFEST_PARSED, () => { + + // Filter HEVC levels for proxy attempt too + hlsProxy.on(Hls.Events.MANIFEST_PARSED, (_, data) => { + filterHEVCLevels(hlsProxy); markLoaded(); video.play().catch(() => {}); }); @@ -292,10 +297,30 @@ export function IPTVPlayer({ channel, onClose, channels, onChannelChange, channe }); }; + // 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 direct URL with HLS.js hls.loadSource(url); hls.attachMedia(video); - hls.on(Hls.Events.MANIFEST_PARSED, () => { + hls.on(Hls.Events.MANIFEST_PARSED, (_, data) => { + // Filter HEVC levels to prevent audio-only playback + filterHEVCLevels(hls); markLoaded(); video.play().catch(() => {}); }); diff --git a/components/player/DesktopVideoPlayer.tsx b/components/player/DesktopVideoPlayer.tsx index c021fbd..aeb179a 100644 --- a/components/player/DesktopVideoPlayer.tsx +++ b/components/player/DesktopVideoPlayer.tsx @@ -6,6 +6,7 @@ import { useDesktopPlayerLogic } from './hooks/useDesktopPlayerLogic'; import { useHlsPlayer } from './hooks/useHlsPlayer'; import { useAutoSkip } from './hooks/useAutoSkip'; import { useStallDetection } from './hooks/useStallDetection'; +import { useVideoResolution } from './hooks/useVideoResolution'; import { DesktopControlsWrapper } from './desktop/DesktopControlsWrapper'; import { DesktopOverlayWrapper } from './desktop/DesktopOverlayWrapper'; import { DanmakuCanvas } from './DanmakuCanvas'; @@ -51,6 +52,9 @@ export function DesktopVideoPlayer({ const isIOS = useIsIOS(); const isMobile = useIsMobile(); + // Detect actual video resolution + const videoResolution = useVideoResolution(refs.videoRef); + // Danmaku const { danmakuEnabled, setDanmakuEnabled, comments: danmakuComments } = useDanmaku({ videoTitle, @@ -231,6 +235,16 @@ export function DesktopVideoPlayer({ /> )} + {/* Video Resolution Badge - shows actual resolution from video stream */} + {videoResolution && ( +
+ + {videoResolution.label} + {videoResolution.width}x{videoResolution.height} + +
+ )} +
diff --git a/components/player/VideoPlayerError.tsx b/components/player/VideoPlayerError.tsx index c416c90..8f949e6 100644 --- a/components/player/VideoPlayerError.tsx +++ b/components/player/VideoPlayerError.tsx @@ -40,6 +40,13 @@ export function VideoPlayerError({

播放失败

{error}

+ {/* Browser compatibility hint */} + {(error.includes('不支持') || error.includes('格式') || error.includes('编码')) && ( +

+ 建议使用 Chrome、Edge 或 Safari 浏览器以获得最佳兼容性 +

+ )} + {/* Action Buttons */}