网络电视播放器 v1.0
基于 PyQt5 + VLC 的桌面网络电视播放器,左侧频道列表切换,右侧实时播放,支持音量调节、截图、全屏、收藏、自动切换备用源。
作者信息
运行方式
# 1. 安装 VLC 播放器(必须)# 下载地址: https://www.videolan.org/# Windows 安装 64 位版本# 2. 安装 Python 依赖pip install PyQt5 python-vlc# 3. 启动python ysp/code/tv_player.py
快捷键
界面布局
+------------------+-----------------------------------------------+| 频道列表 | |+------------------+ || 搜索频道... | |+------------------+ || * 收藏频道 | || CCTV-1 综合 | 视频播放区域 || > 央视频道 | (VLC 渲染) || CCTV-1 综合 | || CCTV-2 财经 | || CCTV-3 综艺 | || ... | || > 卫视频道 | || 湖南卫视 | || 浙江卫视 | || ... | || > 地方/其他 |-----------------------------------------------+| 共 20 个频道 | [播放] [停止] 音量[====] 截图 全屏 CCTV-1 |+------------------+-----------------------------------------------+
功能说明
频道列表(左侧)
- 20 个预置频道,分 3 组:央视(10)、卫视(8)、地方/其他(2)
视频播放(右侧)
- 基于 VLC 引擎,支持 M3U8/HTTP/RTSP 全协议
自动切换备用源
播放控制(底部)
- 截图按钮:保存到 screenshots/ 目录,文件名含频道名+时间戳
数据持久化
预置频道列表
央视频道(10个)
| |
|---|
| http://ivi.bupt.edu.cn/hls/cctv1hd.m3u8 |
| http://ivi.bupt.edu.cn/hls/cctv2hd.m3u8 |
| http://ivi.bupt.edu.cn/hls/cctv3hd.m3u8 |
| http://ivi.bupt.edu.cn/hls/cctv4hd.m3u8 |
| http://ivi.bupt.edu.cn/hls/cctv5hd.m3u8 |
| http://ivi.bupt.edu.cn/hls/cctv6hd.m3u8 |
| http://ivi.bupt.edu.cn/hls/cctv7hd.m3u8 |
| http://ivi.bupt.edu.cn/hls/cctv8hd.m3u8 |
| http://ivi.bupt.edu.cn/hls/cctv9hd.m3u8 |
| http://ivi.bupt.edu.cn/hls/cctv10hd.m3u8 |
卫视频道(8个)
| |
|---|
| http://219.151.31.38/.../hnwshd/4000000/mnf.m3u8 |
| http://121.24.98.226:8090/hls/38/index.m3u8 |
| http://121.24.98.226:8090/hls/39/index.m3u8 |
| http://121.24.98.226:8090/hls/40/index.m3u8 |
| http://ivi.bupt.edu.cn/hls/btv1hd.m3u8 |
| http://tv.gdtv.ah.cn/live/01.m3u8 |
| http://124.160.184.108/.../3bfabc1fe16a4282b50ea095928c1f60.m3u8 |
| rtsp://218.6.174.207/sctv1 |
地方/其他(2个)
| |
|---|
| http://121.24.98.226:8090/hls/121/index.m3u8 |
| http://121.24.98.226:8090/hls/168/index.m3u8 |
关键代码解析
关键代码一:VLC 播放器封装与自动切换备用源
classVLCPlayer:defplay(self, urls, name=""):"""播放频道,urls 为备用源列表""" self.current_urls = urls # 保存所有备用源 self.current_url_idx = 0# 当前使用第几个源 self._play_url(urls[0]) # 先尝试第一个deftry_next_source(self):"""当前源失败,自动切换下一个""" self.current_url_idx += 1if self.current_url_idx < len(self.current_urls): self._play_url(self.current_urls[self.current_url_idx])returnTrue# 还有备用源returnFalse# 所有源都试完了def_play_url(self, url): media = self.instance.media_new(url) media.add_option(":network-caching=1000") # 1秒网络缓存 self.player.set_media(media) self.player.play()
设计思路:每个频道配置一个 URL 列表而非单个地址。播放时从第一个开始,主窗口的定时器每 3 秒检测播放状态,连续 2 次检测到 Error 状态就调用 try_next_source() 切换到下一个备用源,直到所有源都尝试完毕。
应用场景:公开 IPTV 源不稳定,同一频道配置 2-3 个不同 CDN 节点的地址,一个挂了自动切另一个,用户无感知。
关键代码二:频道列表分组 + 搜索过滤 + 收藏置顶
def_populate_channels(self):# 1. 收藏分组置顶if self.favorites: fav_group = QTreeWidgetItem(["收藏频道"])for group, channels in CHANNELS.items():for ch in channels:if ch["name"] in self.favorites: item = QTreeWidgetItem(fav_group, [ch["name"]]) item.setData(0, Qt.UserRole, ch["urls"])# 2. 按分组填充所有频道for group, channels in CHANNELS.items(): group_item = QTreeWidgetItem([group])for ch in channels: item = QTreeWidgetItem(group_item, [ch["name"]]) item.setData(0, Qt.UserRole, ch["urls"]) # URL列表存在UserRole item.setData(0, Qt.UserRole+1, ch["name"]) # 频道名存在UserRole+1def_filter_channels(self, keyword):# 遍历所有分组和子项,隐藏不匹配的for group in all_groups: visible = 0for child in group.children: match = keyword in child.text().lower() child.setHidden(not match)if match: visible += 1 group.setHidden(visible == 0)
设计思路:QTreeWidget 天然支持分组折叠。频道的 URL 列表和名称通过 setData(Qt.UserRole) 存储在 item 上,点击时直接取出传给 VLC 播放。搜索过滤只操作 setHidden(),不重建树,性能好。收藏列表持久化到 JSON 文件。
关键代码三:播放状态监控与自动重连
def_start_monitor(self): self.monitor_timer = QTimer() self.monitor_timer.timeout.connect(self._check_state) self.monitor_timer.start(3000) # 每3秒检查一次 self._error_count = 0def_check_state(self): state = self.vlc_player.get_state()if state == Playing: self.lbl_state.setText("播放中") self._error_count = 0# 正常播放,重置错误计数elif state == Error: self._error_count += 1if self._error_count >= 2: # 连续2次错误才切换(避免误判)if self.vlc_player.try_next_source(): self.lbl_state.setText("切换备用源...") self._error_count = 0else: self.lbl_state.setText("所有源均不可用")elif state in (Opening, Buffering): self.lbl_state.setText("加载中...")
设计思路:QTimer 每 3 秒轮询 VLC 播放状态。用 _error_count 做防抖,避免网络波动导致的瞬间错误触发切源。连续 2 次(6秒)检测到 Error 才认定当前源不可用。状态栏用不同颜色区分:绿色=播放中,橙色=加载/切源,红色=全部失败。
代码架构
tv_player.py+-- CHANNELS (dict) # 预置频道源(分组 -> 频道列表 -> 多备用URL)+-- CONFIG_FILE # 配置文件路径+-- VLCPlayer # VLC 播放器封装| +-- play(urls) # 播放(传入备用源列表)| +-- try_next_source() # 自动切换备用源| +-- stop/pause # 停止/暂停| +-- set_volume # 音量控制| +-- snapshot # 截图+-- TVPlayerApp # 主窗口 +-- 左侧: QTreeWidget # 频道列表(分组+搜索+收藏) +-- 右侧: QFrame # VLC 视频渲染区 +-- 底部: 控制栏 # 播放/音量/截图/全屏 +-- QTimer # 状态监控(3秒轮询,自动切源) +-- JSON 持久化 # 上次频道/收藏/窗口大小
自定义频道
在代码顶部的 CHANNELS 字典中添加即可:
CHANNELS = {"央视频道": [...],"卫视频道": [...],"我的频道": [ # 新增分组 {"name": "自定义台1", "urls": ["http://xxx/stream1.m3u8", # 主源"http://yyy/stream1.m3u8", # 备用源 ]}, ],}
每个频道的 urls 列表可以放多个地址,播放失败会自动按顺序切换。
常见问题
| |
|---|
| 启动报错 "No module named vlc" | pip install python-vlc |
| |
| |
| screenshots/ 目录下,文件名含频道名和时间戳 |
| |
| RTSP 对网络要求较高,建议优先使用 M3U8 源 |
依赖
PyQt5python-vlc
https://www.videolan.org/vlc/
另需安装 VLC 播放器(https://www.videolan.org/),Windows 选 64 位版本。
-- coding: utf-8 --"""网络电视播放器 Lite v1.2 - PyQt5 + mpv功能:频道切换/本地视频/播放控制/音量/截图/全屏/主题/导入导出作者:杨少平 | 公众号:Python学在坚持运行:python tv_player_lite.py依赖:pip install PyQt5 python-mpv前提:Windows需下载mpv-dev并把libmpv-2.dll放到脚本同目录或系统PATH下载: https://sourceforge.net/projects/mpv-player-windows/files/libmpv/"""import sys, os, json, localefrom datetime import datetimefrom PyQt5.QtWidgets import (QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,QLabel, QLineEdit, QPushButton, QSlider, QTreeWidget, QTreeWidgetItem,QSplitter, QFrame, QFileDialog, QMessageBox, QComboBox, QMenu, QShortcut)from PyQt5.QtCore import Qt, QTimerfrom PyQt5.QtGui import QFont, QColor, QKeySequenceCHANNELS = {"央视频道": [{"name": "CCTV-1 综合", "urls": ["http://ivi.bupt.edu.cn/hls/cctv1hd.m3u8"]},{"name": "CCTV-2 财经", "urls": ["http://ivi.bupt.edu.cn/hls/cctv2hd.m3u8"]},{"name": "CCTV-3 综艺", "urls": ["http://ivi.bupt.edu.cn/hls/cctv3hd.m3u8"]},{"name": "CCTV-4 中文国际", "urls": ["http://ivi.bupt.edu.cn/hls/cctv4hd.m3u8"]},{"name": "CCTV-5 体育", "urls": ["http://ivi.bupt.edu.cn/hls/cctv5hd.m3u8"]},{"name": "CCTV-6 电影", "urls": ["http://ivi.bupt.edu.cn/hls/cctv6hd.m3u8"]},{"name": "CCTV-7 国防军事", "urls": ["http://ivi.bupt.edu.cn/hls/cctv7hd.m3u8"]},{"name": "CCTV-8 电视剧", "urls": ["http://ivi.bupt.edu.cn/hls/cctv8hd.m3u8"]},{"name": "CCTV-9 纪录", "urls": ["http://ivi.bupt.edu.cn/hls/cctv9hd.m3u8"]},{"name": "CCTV-10 科教", "urls": ["http://ivi.bupt.edu.cn/hls/cctv10hd.m3u8"]},],"卫视频道": [{"name": "湖南卫视", "urls": ["http://219.151.31.38/liveplay-kk.rtxapp.com/live/program/live/hnwshd/4000000/mnf.m3u8"]},{"name": "江苏卫视", "urls": ["http://121.24.98.226:8090/hls/38/index.m3u8"]},{"name": "浙江卫视", "urls": ["http://121.24.98.226:8090/hls/39/index.m3u8"]},{"name": "东方卫视", "urls": ["http://121.24.98.226:8090/hls/40/index.m3u8"]},{"name": "北京卫视", "urls": ["http://ivi.bupt.edu.cn/hls/btv1hd.m3u8"]},{"name": "广东卫视", "urls": ["http://tv.gdtv.ah.cn/live/01.m3u8"]},{"name": "湖北卫视", "urls": ["http://124.160.184.108/live/5/45/3bfabc1fe16a4282b50ea095928c1f60.m3u8"]},],"地方/其他": [{"name": "山东齐鲁频道", "urls": ["http://121.24.98.226:8090/hls/121/index.m3u8"]},{"name": "凤凰卫视中文台", "urls": ["http://121.24.98.226:8090/hls/168/index.m3u8"]},],}CONFIG_FILE = os.path.join(os.path.dirname(os.path.abspath(file)), ".tv_lite_config.json")THEMES = {"经典白": {"left_bg":"#f5f5f5","title_bg":"#e0e0e0","search_bg":"#fff","search_fg":"#333","tree_bg":"#f5f5f5","tree_fg":"#333","tree_hover":"#e3f2fd","tree_sel":"#1976d2","tree_sel_fg":"#fff","ctrl_bg":"#e0e0e0","accent":"#1976d2","btn_fg":"#fff","btn_bg":"#1976d2","video_bg":"#222","status_bg":"#e0e0e0","status_fg":"#555","group_fg":"#1976d2","count_fg":"#888","channel_fg":"#1976d2","state_fg":"#888","slider_groove":"#bbb","slider_handle":"#1976d2","slider_sub":"#1976d2",},"暗夜蓝": {"left_bg":"#1a1a2e","title_bg":"#16213e","search_bg":"#0f3460","search_fg":"#fff","tree_bg":"#1a1a2e","tree_fg":"#e0e0e0","tree_hover":"#0f3460","tree_sel":"#e94560","tree_sel_fg":"#fff","ctrl_bg":"#16213e","accent":"#e94560","btn_fg":"#fff","btn_bg":"#0f3460","video_bg":"#000","status_bg":"#16213e","status_fg":"#888","group_fg":"#e94560","count_fg":"#888","channel_fg":"#e94560","state_fg":"#888","slider_groove":"#333","slider_handle":"#e94560","slider_sub":"#e94560",},"护眼绿": {"left_bg":"#1b2e1b","title_bg":"#2e4a2e","search_bg":"#3a5f3a","search_fg":"#c8e6c9","tree_bg":"#1b2e1b","tree_fg":"#c8e6c9","tree_hover":"#2e7d32","tree_sel":"#66bb6a","tree_sel_fg":"#000","ctrl_bg":"#2e4a2e","accent":"#66bb6a","btn_fg":"#fff","btn_bg":"#2e7d32","video_bg":"#000","status_bg":"#2e4a2e","status_fg":"#a5d6a7","group_fg":"#66bb6a","count_fg":"#a5d6a7","channel_fg":"#66bb6a","state_fg":"#a5d6a7","slider_groove":"#333","slider_handle":"#66bb6a","slider_sub":"#66bb6a",},"暖橙": {"left_bg":"#2e1a0e","title_bg":"#4e2a0e","search_bg":"#6d3a1a","search_fg":"#ffe0b2","tree_bg":"#2e1a0e","tree_fg":"#ffe0b2","tree_hover":"#e65100","tree_sel":"#ff9800","tree_sel_fg":"#000","ctrl_bg":"#4e2a0e","accent":"#ff9800","btn_fg":"#fff","btn_bg":"#e65100","video_bg":"#000","status_bg":"#4e2a0e","status_fg":"#ffcc80","group_fg":"#ff9800","count_fg":"#ffcc80","channel_fg":"#ff9800","state_fg":"#ffcc80","slider_groove":"#333","slider_handle":"#ff9800","slider_sub":"#ff9800",},"紫罗兰": {"left_bg":"#1a0e2e","title_bg":"#2a1a4e","search_bg":"#3a2a6d","search_fg":"#e1bee7","tree_bg":"#1a0e2e","tree_fg":"#e1bee7","tree_hover":"#6a1b9a","tree_sel":"#ab47bc","tree_sel_fg":"#fff","ctrl_bg":"#2a1a4e","accent":"#ab47bc","btn_fg":"#fff","btn_bg":"#6a1b9a","video_bg":"#000","status_bg":"#2a1a4e","status_fg":"#ce93d8","group_fg":"#ab47bc","count_fg":"#ce93d8","channel_fg":"#ab47bc","state_fg":"#ce93d8","slider_groove":"#333","slider_handle":"#ab47bc","slider_sub":"#ab47bc",},}==================== mpv 播放器封装 ====================class MpvPlayer:"""封装 python-mpv,嵌入到 QWidget"""def __init__(self, wid): import mpv locale.setlocale(locale.LC_NUMERIC, "C") # mpv 需要 self.player = mpv.MPV( wid=str(int(wid)), vo="gpu", hwdec="auto", keep_open="yes", input_default_bindings=False, input_vo_keyboard=False, osc=False, log_handler=lambda *a: None, ) self.player.volume = 80 self.current_urls = [] self.current_url_idx = 0 self.current_name = "" self._playing = Falsedef play(self, urls, name=""): self.current_urls = list(urls) self.current_url_idx = 0 self.current_name = name self._play_url(urls[0])def play_file(self, path): """播放本地文件""" self.current_urls = [path] self.current_url_idx = 0 self.current_name = os.path.basename(path) self._play_url(path)def _play_url(self, url): try: self.player.play(url) self._playing = True except Exception as e: print(f"mpv play error: {e}") self._playing = Falsedef try_next_source(self): self.current_url_idx += 1 if self.current_url_idx < len(self.current_urls): self._play_url(self.current_urls[self.current_url_idx]) return True return Falsedef stop(self): try: self.player.command("stop") except Exception: pass self._playing = Falsedef pause(self): try: self.player.cycle("pause") except Exception: pass@propertydef is_paused(self): try: return self.player.pause except Exception: return False@propertydef is_idle(self): try: return self.player.idle_active except Exception: return Truedef set_volume(self, vol): try: self.player.volume = max(0, min(100, vol)) except Exception: passdef get_volume(self): try: return int(self.player.volume or 80) except Exception: return 80def toggle_mute(self): try: self.player.cycle("mute") except Exception: pass@propertydef is_muted(self): try: return self.player.mute except Exception: return Falsedef screenshot(self, path): try: self.player.screenshot_to_file(path, "video") return True except Exception: return Falsedef get_state_text(self): try: if self.player.idle_active: return "就绪" if self.player.pause: return "已暂停" if self.player.core_idle: return "缓冲中..." return "播放中" except Exception: return "就绪"def is_error(self): """检测是否播放失败""" try: return self.player.idle_active and self._playing except Exception: return Falsedef terminate(self): try: self.player.terminate() except Exception: pass==================== 主窗口 ====================class TVPlayerLite(QMainWindow):def init(self):super().init()self.setWindowTitle("TV Player Lite v1.2 | Python学在坚持")self.setMinimumSize(960, 600)self.resize(1200, 720)self.mpv = Noneself.current_channel = ""self.current_urls = []self.current_url_idx = 0self.favorites = set()self.channels = dict(CHANNELS)self.theme_name = "经典白"self._click_cooldown = False # 防双击卡顿self._load_config()self._build()self._apply_theme(self.theme_name)self._init_mpv()self._start_monitor()def _build(self): central = QWidget() self.setCentralWidget(central) root = QHBoxLayout(central) root.setContentsMargins(0, 0, 0, 0) root.setSpacing(0) self.splitter = QSplitter(Qt.Horizontal) # ---- 左侧 ---- self.left_widget = QWidget() self.left_widget.setMaximumWidth(270) self.left_widget.setMinimumWidth(180) ll = QVBoxLayout(self.left_widget) ll.setContentsMargins(0, 0, 0, 0) ll.setSpacing(0) self.lbl_title = QLabel(" TV Player Lite") self.lbl_title.setFixedHeight(40) ll.addWidget(self.lbl_title) # 工具行:主题+导入+导出+本地视频 tool_row = QHBoxLayout() tool_row.setContentsMargins(6, 4, 6, 4) self.cb_theme = QComboBox() self.cb_theme.addItems(list(THEMES.keys())) self.cb_theme.setCurrentText(self.theme_name) self.cb_theme.currentTextChanged.connect(self._on_theme_change) tool_row.addWidget(self.cb_theme, 1) btn_imp = QPushButton("导入") btn_imp.setFixedWidth(36) btn_imp.setToolTip("导入频道JSON") btn_imp.clicked.connect(self._import_channels) tool_row.addWidget(btn_imp) btn_exp = QPushButton("导出") btn_exp.setFixedWidth(36) btn_exp.setToolTip("导出频道JSON") btn_exp.clicked.connect(self._export_channels) tool_row.addWidget(btn_exp) ll.addLayout(tool_row) # 搜索 self.search_box = QLineEdit() self.search_box.setPlaceholderText(" 搜索频道...") self.search_box.textChanged.connect(self._filter) ll.addWidget(self.search_box) # 频道树 self.tree = QTreeWidget() self.tree.setHeaderHidden(True) self.tree.itemClicked.connect(self._on_click) self.tree.setContextMenuPolicy(Qt.CustomContextMenu) self.tree.customContextMenuRequested.connect(self._ctx_menu) ll.addWidget(self.tree, 1) self.lbl_count = QLabel("") ll.addWidget(self.lbl_count) # 本地视频按钮 btn_local = QPushButton("📂 打开本地视频") btn_local.setStyleSheet("margin:4px 6px;padding:8px;font-size:12px;") btn_local.clicked.connect(self._open_local) ll.addWidget(btn_local) self.btn_local = btn_local self._fill_tree() self.splitter.addWidget(self.left_widget) # ---- 右侧 ---- right = QWidget() rl = QVBoxLayout(right) rl.setContentsMargins(0, 0, 0, 0) rl.setSpacing(0) # 视频区域(mpv 渲染到这个 QFrame) self.video_frame = QFrame() self.video_frame.setMinimumSize(480, 320) rl.addWidget(self.video_frame, 1) # 控制栏 self.ctrl_bar = QWidget() self.ctrl_bar.setFixedHeight(50) cl = QHBoxLayout(self.ctrl_bar) cl.setContentsMargins(10, 0, 10, 0) cl.setSpacing(8) self.btn_play = QPushButton("▶") self.btn_play.setFixedSize(36, 36) self.btn_play.clicked.connect(self._toggle_play) cl.addWidget(self.btn_play) self.btn_stop = QPushButton("⏹") self.btn_stop.setFixedSize(36, 36) self.btn_stop.clicked.connect(self._stop) cl.addWidget(self.btn_stop) cl.addSpacing(10) self.btn_mute = QPushButton("🔊") self.btn_mute.setFixedSize(30, 30) self.btn_mute.clicked.connect(self._toggle_mute) cl.addWidget(self.btn_mute) self.sl_vol = QSlider(Qt.Horizontal) self.sl_vol.setRange(0, 100) self.sl_vol.setValue(80) self.sl_vol.setMaximumWidth(120) self.sl_vol.valueChanged.connect(self._vol_change) cl.addWidget(self.sl_vol) self.lbl_vol = QLabel("80%") self.lbl_vol.setMinimumWidth(35) cl.addWidget(self.lbl_vol) cl.addSpacing(10) self.btn_snap = QPushButton("截图") self.btn_snap.clicked.connect(self._snapshot) cl.addWidget(self.btn_snap) self.btn_full = QPushButton("全屏") self.btn_full.clicked.connect(self._toggle_fs) cl.addWidget(self.btn_full) cl.addStretch() self.lbl_ch = QLabel("未选择频道") cl.addWidget(self.lbl_ch) self.lbl_state = QLabel("就绪") cl.addWidget(self.lbl_state) rl.addWidget(self.ctrl_bar) self.splitter.addWidget(right) self.splitter.setStretchFactor(0, 0) self.splitter.setStretchFactor(1, 1) root.addWidget(self.splitter) # 快捷键 QShortcut(QKeySequence("F11"), self, self._toggle_fs) QShortcut(QKeySequence("Escape"), self, lambda: self.showNormal() if self.isFullScreen() else None) QShortcut(QKeySequence("Space"), self, self._toggle_play) QShortcut(QKeySequence("Ctrl+O"), self, self._open_local) self.statusBar().showMessage("公众号: Python学在坚持 | 微信: ysp2338084 | 作者: 杨少平")# ---- mpv 初始化 ----def _init_mpv(self): try: wid = int(self.video_frame.winId()) self.mpv = MpvPlayer(wid) except OSError as e: err = str(e) if "libmpv" in err or "DLL" in err or "cannot" in err.lower(): QMessageBox.critical(self, "缺少 mpv 库", "未找到 libmpv 动态库。\n\n" "Windows 安装步骤:\n" "1. 下载 mpv-dev: https://sourceforge.net/projects/mpv-player-windows/files/libmpv/\n" "2. 解压后将 libmpv-2.dll 复制到本脚本同目录\n" "3. 或复制到 C:\\Windows\\System32\\\n\n" "然后重新运行程序。") else: QMessageBox.critical(self, "mpv 初始化失败", f"错误: {e}") except ImportError: QMessageBox.critical(self, "缺少 python-mpv", "请安装: pip install python-mpv\n\n" "同时需要 libmpv 动态库(见上方说明)") except Exception as e: QMessageBox.critical(self, "mpv 初始化失败", f"错误: {e}")# ---- 频道树 ----def _fill_tree(self): self.tree.clear() total = 0 if self.favorites: fg = QTreeWidgetItem(self.tree, ["⭐ 收藏频道"]) for g, chs in self.channels.items(): for ch in chs: if ch["name"] in self.favorites: it = QTreeWidgetItem(fg, [ch["name"]]) it.setData(0, Qt.UserRole, ch["urls"]) it.setData(0, Qt.UserRole + 1, ch["name"]) fg.setExpanded(True) for g, chs in self.channels.items(): gi = QTreeWidgetItem(self.tree, [g]) f = gi.font(0); f.setBold(True); gi.setFont(0, f) for ch in chs: it = QTreeWidgetItem(gi, [ch["name"]]) it.setData(0, Qt.UserRole, ch["urls"]) it.setData(0, Qt.UserRole + 1, ch["name"]) total += 1 gi.setExpanded(True) self.lbl_count.setText(f" 共 {total} 个频道")def _filter(self, kw): kw = kw.lower() for i in range(self.tree.topLevelItemCount()): g = self.tree.topLevelItem(i) vis = 0 for j in range(g.childCount()): c = g.child(j) m = kw in c.text(0).lower() c.setHidden(not m) if m: vis += 1 g.setHidden(vis == 0 and kw != "")def _ctx_menu(self, pos): it = self.tree.itemAt(pos) if not it or not it.data(0, Qt.UserRole): return name = it.data(0, Qt.UserRole + 1) menu = QMenu() if name in self.favorites: menu.addAction("取消收藏", lambda: self._toggle_fav(name)) else: menu.addAction("⭐ 收藏", lambda: self._toggle_fav(name)) menu.exec_(self.tree.viewport().mapToGlobal(pos))def _toggle_fav(self, name): if name in self.favorites: self.favorites.discard(name) else: self.favorites.add(name) self._save_config(); self._fill_tree(); self._apply_theme(self.theme_name)# ---- 播放(防双击卡顿) ----def _on_click(self, item, col): urls = item.data(0, Qt.UserRole) name = item.data(0, Qt.UserRole + 1) if not urls: return # 防双击:500ms 冷却 if self._click_cooldown: return self._click_cooldown = True QTimer.singleShot(500, lambda: setattr(self, '_click_cooldown', False)) self.current_channel = name self.current_urls = list(urls) self.current_url_idx = 0 self.lbl_ch.setText(name) self.lbl_state.setText("加载中...") if self.mpv: self.mpv.play(urls, name) self.btn_play.setText("⏸") self._save_config()def _open_local(self): """打开本地视频文件""" path, _ = QFileDialog.getOpenFileName( self, "打开本地视频", "", "视频文件 (*.mp4 *.avi *.mkv *.mov *.flv *.wmv *.ts *.m3u8);;所有文件 (*)" ) if not path: return self.current_channel = os.path.basename(path) self.current_urls = [path] self.current_url_idx = 0 self.lbl_ch.setText(self.current_channel) self.lbl_state.setText("加载中...") if self.mpv: self.mpv.play_file(path) self.btn_play.setText("⏸")def _toggle_play(self): if not self.mpv: return self.mpv.pause() if self.mpv.is_paused: self.btn_play.setText("▶") else: self.btn_play.setText("⏸")def _stop(self): if self.mpv: self.mpv.stop() self.btn_play.setText("▶") self.lbl_state.setText("已停止")def _vol_change(self, v): self.lbl_vol.setText(f"{v}%") if self.mpv: self.mpv.set_volume(v) self.btn_mute.setText("🔇" if v == 0 else "🔊")def _toggle_mute(self): if not self.mpv: return self.mpv.toggle_mute() self.btn_mute.setText("🔇" if self.mpv.is_muted else "🔊")def _snapshot(self): if not self.mpv: return d = os.path.join(os.path.dirname(os.path.abspath(__file__)), "screenshots") os.makedirs(d, exist_ok=True) ts = datetime.now().strftime("%Y%m%d_%H%M%S") ch = self.current_channel.replace(" ", "_").replace("/", "_") or "snap" p = os.path.join(d, f"{ch}_{ts}.png") if self.mpv.screenshot(p): self.lbl_state.setText(f"已截图: {os.path.basename(p)}") self.statusBar().showMessage(f"截图: {p}", 5000) else: self.lbl_state.setText("截图失败")def _toggle_fs(self): if self.isFullScreen(): self.showNormal() else: self.showFullScreen()# ---- 导入导出 ----def _export_channels(self): p, _ = QFileDialog.getSaveFileName(self, "导出频道", "channels.json", "JSON (*.json)") if p: with open(p, "w", encoding="utf-8") as f: json.dump(self.channels, f, ensure_ascii=False, indent=2) total = sum(len(v) for v in self.channels.values()) QMessageBox.information(self, "成功", f"已导出 {total} 个频道到:\n{p}")def _import_channels(self): p, _ = QFileDialog.getOpenFileName(self, "导入频道", "", "JSON (*.json)") if not p: return try: with open(p, "r", encoding="utf-8") as f: data = json.load(f) if not isinstance(data, dict): raise ValueError("顶层应为字典") count = 0 for g, chs in data.items(): if not isinstance(chs, list): continue for ch in chs: if "name" in ch and "urls" in ch: count += 1 if count == 0: raise ValueError("未找到有效频道") self.channels = data self._fill_tree(); self._apply_theme(self.theme_name) QMessageBox.information(self, "成功", f"已导入 {count} 个频道") except Exception as e: QMessageBox.warning(self, "导入失败", f"格式错误: {e}")# ---- 主题 ----def _on_theme_change(self, name): self.theme_name = name self._apply_theme(name) self._save_config()def _apply_theme(self, name): t = THEMES.get(name, THEMES["经典白"]) self.left_widget.setStyleSheet(f"background:{t['left_bg']};") self.lbl_title.setStyleSheet(f"color:{t['tree_fg']};font-size:15px;font-weight:bold;padding:8px;background:{t['title_bg']};") self.search_box.setStyleSheet(f"padding:7px;margin:4px 6px;border:1px solid {t['slider_groove']};border-radius:4px;background:{t['search_bg']};color:{t['search_fg']};") self.tree.setStyleSheet(f""" QTreeWidget {{ background:{t['tree_bg']}; color:{t['tree_fg']}; border:none; font-size:13px; }} QTreeWidget::item {{ padding:5px 8px; }} QTreeWidget::item:hover {{ background:{t['tree_hover']}; }} QTreeWidget::item:selected {{ background:{t['tree_sel']}; color:{t['tree_sel_fg']}; }} QTreeWidget::branch {{ background:{t['tree_bg']}; }} """) self.lbl_count.setStyleSheet(f"color:{t['count_fg']};font-size:11px;padding:4px 6px;background:{t['left_bg']};") self.btn_local.setStyleSheet(f"margin:4px 6px;padding:8px;font-size:12px;background:{t['btn_bg']};color:{t['btn_fg']};border:none;border-radius:4px;") self.ctrl_bar.setStyleSheet(f"background:{t['ctrl_bg']};") self.btn_play.setStyleSheet(f"background:{t['accent']};color:{t['btn_fg']};border:none;border-radius:18px;font-size:16px;") self.btn_stop.setStyleSheet(f"background:{t['slider_groove']};color:{t['btn_fg']};border:none;border-radius:18px;font-size:14px;") self.btn_mute.setStyleSheet(f"background:transparent;color:{t['tree_fg']};border:none;font-size:16px;") self.sl_vol.setStyleSheet(f""" QSlider::groove:horizontal {{ background:{t['slider_groove']}; height:4px; border-radius:2px; }} QSlider::handle:horizontal {{ background:{t['slider_handle']}; width:14px; height:14px; margin:-5px 0; border-radius:7px; }} QSlider::sub-page:horizontal {{ background:{t['slider_sub']}; border-radius:2px; }} """) self.lbl_vol.setStyleSheet(f"color:{t['state_fg']};font-size:11px;") for b in [self.btn_snap, self.btn_full]: b.setStyleSheet(f"background:{t['btn_bg']};color:{t['btn_fg']};border:none;border-radius:4px;padding:6px 12px;font-size:12px;") self.lbl_ch.setStyleSheet(f"color:{t['channel_fg']};font-size:13px;font-weight:bold;") self.lbl_state.setStyleSheet(f"color:{t['state_fg']};font-size:11px;margin-left:8px;") self.video_frame.setStyleSheet(f"background:{t['video_bg']};") self.statusBar().setStyleSheet(f"background:{t['status_bg']};color:{t['status_fg']};") for i in range(self.tree.topLevelItemCount()): self.tree.topLevelItem(i).setForeground(0, QColor(t["group_fg"]))# ---- 状态监控+自动切源 ----def _start_monitor(self): self._err_count = 0 self.mon = QTimer() self.mon.timeout.connect(self._check) self.mon.start(3000)def _check(self): if not self.mpv: return state = self.mpv.get_state_text() if state == "播放中": self.lbl_state.setText("▶ 播放中") self.lbl_state.setStyleSheet("color:#4caf50;font-size:11px;margin-left:8px;") self._err_count = 0 elif state == "缓冲中...": self.lbl_state.setText("⏳ 缓冲中...") self.lbl_state.setStyleSheet("color:#ff9800;font-size:11px;margin-left:8px;") elif state == "已暂停": self.lbl_state.setText("⏸ 已暂停") elif self.mpv.is_error(): self._err_count += 1 if self._err_count >= 2: if self.mpv.try_next_source(): idx = self.mpv.current_url_idx total = len(self.mpv.current_urls) self.lbl_state.setText(f"⚠ 切换备用源 {idx+1}/{total}...") self.lbl_state.setStyleSheet("color:#ff9800;font-size:11px;margin-left:8px;") self._err_count = 0 else: self.lbl_state.setText("❌ 所有源不可用") self.lbl_state.setStyleSheet("color:#e53935;font-size:11px;margin-left:8px;")# ---- 配置 ----def _load_config(self): if os.path.exists(CONFIG_FILE): try: with open(CONFIG_FILE, "r", encoding="utf-8") as f: c = json.load(f) self.current_channel = c.get("last_channel", "") self.favorites = set(c.get("favorites", [])) self.theme_name = c.get("theme", "经典白") g = c.get("geometry") if g: self.resize(g.get("w", 1200), g.get("h", 720)) custom = c.get("custom_channels") if custom and isinstance(custom, dict): self.channels = custom except Exception: passdef _save_config(self): c = { "last_channel": self.current_channel, "favorites": list(self.favorites), "theme": self.theme_name, "geometry": {"w": self.width(), "h": self.height()}, } if self.channels != CHANNELS: c["custom_channels"] = self.channels try: with open(CONFIG_FILE, "w", encoding="utf-8") as f: json.dump(c, f, ensure_ascii=False, indent=2) except Exception: passdef closeEvent(self, e): self._save_config() if self.mpv: self.mpv.terminate() e.accept()==================== 启动 ====================if name == "main":app = QApplication(sys.argv)app.setFont(QFont("Microsoft YaHei", 10))win = TVPlayerLite()win.show()sys.exit(app.exec_())