Files
MediaCast/ui/main_window.py
2026-01-13 15:23:26 +08:00

787 lines
29 KiB
Python
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import sys
import os
import threading
import socket
import resources
from PyQt5.QtCore import Qt
from PyQt5.QtGui import QIcon
from PyQt5.QtWidgets import QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, QLabel, QGroupBox, QFrame, QPushButton, \
QListWidget, QSlider, QFileDialog, QMessageBox, QListWidgetItem, QApplication
from core.device_discovery import DeviceDiscovery
from core.dlna_controller import DLNAController
from core.http_file_server import HTTPFileServer
from core.logger import AppLogger
class IconManager:
"""图标管理器处理Qt资源系统的图标加载"""
@staticmethod
def get_icon(icon_name="icon"):
"""
从Qt资源系统加载图标
支持格式优先级:
1. 根据平台自动选择格式
2. 使用资源别名
3. 使用完整路径
"""
# 根据平台选择图标格式
platform = sys.platform
if platform == "win32":
extensions = [".ico", ".png"]
elif platform == "darwin": # macOS
extensions = [".icns", ".png"]
else: # Linux和其他
extensions = [".png", ".svg"]
# 尝试不同的路径格式
icon = QIcon()
for ext in extensions:
resource_path = f":/icons/{icon_name}{ext}"
icon = QIcon(resource_path)
if not icon.isNull():
print(f"✓ 从资源加载: {resource_path}")
return icon
# 如果都没找到,创建空图标
if icon.isNull():
print("⚠ 无法从资源加载图标,使用默认图标")
# 可以使用Qt内置图标作为备选
icon = QIcon.fromTheme("application-x-executable")
return icon
class MainWindow(QMainWindow):
"""智能媒体投屏器主窗口"""
def __init__(self):
super().__init__()
self.device_discovery = None
self.dlna_controller = None
self.http_server = None
self.selected_device = None
self.selected_file = None
self.discovered_devices = []
self.logger = AppLogger('MediaCastUI')
self.http_port = 19735
self.is_paused = False # 新增:跟踪暂停状态
self.init_ui()
self.init_connections()
def init_ui(self):
"""初始化UI界面"""
version = "v1.0.3"
app_name = "多媒体投屏 by yqsphp"
self.setWindowTitle(app_name)
self.setGeometry(100, 100, 700, 500)
# 1. 加载主图标
icon = IconManager.get_icon()
# 2. 设置窗口图标(标题栏)
self.setWindowIcon(icon)
# 3. 设置应用程序图标(任务栏)
QApplication.instance().setWindowIcon(icon)
# Windows专用设置AppUserModelID非常重要
if sys.platform == "win32":
try:
from ctypes import windll
# 唯一ID格式公司名.程序名.版本
myappid = app_name + " " + version
windll.shell32.SetCurrentProcessExplicitAppUserModelID(myappid)
except ImportError:
pass
# 创建中央部件
central_widget = QWidget()
self.setCentralWidget(central_widget)
# 主布局
main_layout = QVBoxLayout(central_widget)
main_layout.setSpacing(10)
main_layout.setContentsMargins(10, 10, 10, 10)
# 水平布局用于左右面板
horizontal_layout = QHBoxLayout()
horizontal_layout.setSpacing(10)
# 左侧控制面板
left_panel = self.create_panel()
horizontal_layout.addWidget(left_panel, 1)
# 将水平布局添加到垂直布局中
main_layout.addLayout(horizontal_layout)
# 状态栏
self.statusBar().showMessage("准备就绪")
# 添加弹性空间
main_layout.addStretch()
# 底部版权信息
copyright_label = QLabel(f"© {app_name} {version}")
copyright_label.setAlignment(Qt.AlignCenter)
copyright_label.setStyleSheet("""
color: #999;
font-size: 11px;
padding: 15px 0 5px 0;
border-top: 1px solid #eee;
margin-top: 10px;
""")
main_layout.addWidget(copyright_label)
# 设置扁平化样式
self.setStyleSheet("""
/* 主窗口 */
QMainWindow {
background-color: #f5f5f5;
font-family: 'Microsoft YaHei', 'Segoe UI', sans-serif;
}
/* 分组框 - 扁平化 */
QGroupBox {
font-size: 13px;
border: 1px solid #ddd;
border-radius: 6px;
margin-top: 8px;
padding-top: 8px;
background-color: white;
}
QGroupBox::title {
subcontrol-origin: margin;
left: 10px;
padding: 0 8px 0 8px;
color: #333;
}
/* 按钮 - 扁平化 */
QPushButton {
border: none;
border-radius: 4px;
padding: 13px 10px;
font-size: 15px;
color: white;
background-color: #6c757d;
}
/* 特殊按钮样式 */
#browseBtn{
background:white;
border: 1px solid #ddd;
color:blank;
}
#refreshBtn {
background-color: #28a745;
color: white;
border: none;
}
#startCastBtn {
background-color: #007bff;
color: white;
border: none;
}
#pauseCastBtn {
background-color: #ffc107;
color: white;
border: none;
}
#stopCastBtn {
background-color: #dc3545;
color: white;
border: none;
}
#startCastBtn:disabled,
#pauseCastBtn:disabled,
#stopCastBtn:disabled{
color: #999 !important;
background-color: #f5f5f5 !important;
}
/*音量图标*/
#muteBtn{
background-color:none;
}
/* 列表控件 */
QListWidget {
border: 1px solid #ddd;
border-radius: 4px;
background-color: white;
padding: 4px;
}
QListWidget::item {
padding: 6px 8px;
border-radius: 4px;
border-bottom: 1px solid #f0f0f0;
}
QListWidget::item:selected {
color: white;
background-color: #007bff;
}
/* 文本编辑框 */
QTextEdit {
border: 1px solid #ddd;
border-radius: 4px;
background-color: white;
font-size: 11.5px;
padding: 0 4px;
}
/* 标签 */
QLabel {
color: #333;
font-size: 12px;
}
/* 输入框 */
QLineEdit {
border: 1px solid #ddd;
border-radius: 4px;
padding: 0 8px;
font-size: 12px;
}
""")
def create_panel(self):
"""创建左侧控制面板(文件和设备控制合并)"""
panel = QWidget()
layout = QVBoxLayout(panel)
layout.setSpacing(10)
# ==================== 系统信息区域 ====================
system_group = QGroupBox("系统信息")
system_layout = QVBoxLayout()
system_layout.setSpacing(8)
# 获取本机IP
try:
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.connect(("8.8.8.8", 80))
local_ip = s.getsockname()[0]
s.close()
except:
local_ip = "获取失败"
# 服务器状态
server_frame = QFrame()
server_layout = QHBoxLayout(server_frame)
server_layout.addWidget(QLabel("文件服务:"))
self.ip_label = QLabel(f"{local_ip}:{self.http_port}")
server_layout.addWidget(self.ip_label, 1)
system_layout.addWidget(server_frame)
# 文件服务状态
self.file_service_label = QLabel("就绪")
self.file_service_label.setStyleSheet("color: #28a745;")
server_layout.addWidget(self.file_service_label, 1)
system_group.setLayout(system_layout)
layout.addWidget(system_group)
# ==================== 文件和设备控制区域 ====================
control_group = QGroupBox("文件与设备控制")
control_layout = QVBoxLayout()
control_layout.setSpacing(12)
# 文件选择区域
file_frame = QFrame()
file_layout = QHBoxLayout(file_frame)
file_layout.setSpacing(8)
self.file_label = QLabel("未选择文件")
self.file_label.setWordWrap(True)
self.file_label.setMinimumHeight(25)
self.file_label.setStyleSheet("""
border: 1px solid #dee2e6;
border-radius: 4px;
padding-left: 5px;
""")
file_layout.addWidget(self.file_label, 1)
browse_btn = QPushButton("浏览文件")
browse_btn.setCursor(Qt.PointingHandCursor)
browse_btn.setFixedWidth(80)
browse_btn.setObjectName("browseBtn")
browse_btn.clicked.connect(self.browse_file)
file_layout.addWidget(browse_btn)
control_layout.addWidget(file_frame)
# 设备列表区域
device_frame = QFrame()
device_layout = QVBoxLayout(device_frame)
device_layout.setSpacing(8)
# 设备列表标题和刷新按钮
device_header = QHBoxLayout()
device_header.addWidget(QLabel("设备列表(单击选中):"))
device_header.addStretch()
device_layout.addLayout(device_header)
# 设备列表
self.device_list = QListWidget()
self.device_list.itemClicked.connect(self.select_device)
self.device_list.setMinimumHeight(100)
device_layout.addWidget(self.device_list)
control_layout.addWidget(device_frame)
# 播放控制按钮
playback_frame = QFrame()
playback_layout = QHBoxLayout(playback_frame)
playback_layout.setSpacing(8)
self.refresh_btn = QPushButton("刷新设备")
self.refresh_btn.setObjectName("refreshBtn")
self.refresh_btn.setCursor(Qt.PointingHandCursor)
self.refresh_btn.clicked.connect(self.refresh_devices)
self.start_btn = QPushButton("开始投屏")
self.start_btn.setObjectName("startCastBtn")
self.start_btn.setCursor(Qt.PointingHandCursor)
self.start_btn.setEnabled(False)
self.start_btn.clicked.connect(self.start_casting)
self.pause_btn = QPushButton("暂停投屏")
self.pause_btn.setObjectName("pauseCastBtn")
self.pause_btn.setCursor(Qt.PointingHandCursor)
self.pause_btn.clicked.connect(self.pause_casting)
self.pause_btn.setEnabled(False)
self.stop_btn = QPushButton("结束投屏")
self.stop_btn.setObjectName("stopCastBtn")
self.stop_btn.setCursor(Qt.PointingHandCursor)
self.stop_btn.clicked.connect(self.stop_casting)
self.stop_btn.setEnabled(False)
playback_layout.addWidget(self.refresh_btn)
playback_layout.addWidget(self.start_btn)
playback_layout.addWidget(self.pause_btn)
playback_layout.addWidget(self.stop_btn)
control_layout.addWidget(playback_frame)
# 音量控制
volume_frame = QFrame()
volume_layout = QHBoxLayout(volume_frame)
volume_layout.setSpacing(8)
volume_layout.addWidget(QLabel("音量:"))
self.volume_slider = QSlider(Qt.Horizontal)
self.volume_slider.setRange(0, 100)
self.volume_slider.setValue(50)
self.volume_slider.setCursor(Qt.PointingHandCursor)
self.volume_slider.valueChanged.connect(self.volume_changed)
volume_layout.addWidget(self.volume_slider, 1)
self.mute_btn = QPushButton("🔊")
self.mute_btn.setCheckable(True)
self.mute_btn.setFixedWidth(40)
self.mute_btn.setObjectName("muteBtn")
self.mute_btn.setCursor(Qt.PointingHandCursor)
self.mute_btn.clicked.connect(self.toggle_mute)
volume_layout.addWidget(self.mute_btn)
control_layout.addWidget(volume_frame)
control_group.setLayout(control_layout)
layout.addWidget(control_group, 1) # 主要区域占据更多空间
layout.addStretch()
return panel
def init_connections(self):
"""初始化信号连接"""
# 设备发现
self.refresh_devices()
# 启动HTTP文件服务器
self.start_http_server()
def start_http_server(self):
"""启动HTTP文件服务器"""
try:
self.http_server = HTTPFileServer(self.http_port)
if self.http_server.start():
self.log_message("服务器", "HTTP文件服务器启动成功")
self.file_service_label.setText("运行中")
self.file_service_label.setStyleSheet("color: #20c997; font-weight: bold;")
else:
self.log_message("服务器", "HTTP文件服务器启动失败", "error")
self.file_service_label.setText("异常")
self.file_service_label.setStyleSheet("color: #dc3545; font-weight: bold;")
except Exception as e:
self.log_message("服务器", f"启动HTTP服务器出错: {e}", "error")
def enable_refresh_button(self):
"""启用刷新按钮"""
self.refresh_btn.setEnabled(True)
self.refresh_btn.setText("刷新设备")
def on_discovery_error(self, error_message):
"""设备发现错误"""
self.log_message("设备发现", f"搜索失败: {error_message}", "error")
self.enable_refresh_button()
QMessageBox.warning(self, "设备发现失败", f"搜索设备时出错:\n{error_message}")
def refresh_devices(self):
"""刷新设备列表"""
self.log_message("设备发现", "开始搜索网络中的投屏设备...", "info")
self.refresh_btn.setEnabled(False)
self.refresh_btn.setText("搜索中...")
# 在线程中进行避免阻塞UI
def do_quick_discovery():
if not self.device_discovery:
self.device_discovery = DeviceDiscovery()
# 获取刷新后设备信息
new_device = self.device_discovery.discover_media_renderers()
self.update_device(new_device, self.selected_device)
# 确保在UI线程中更新
thread = threading.Thread(target=do_quick_discovery, daemon=True)
thread.start()
def update_device(self, new_devices, current_selected_device=None):
"""
更新设备列表显示保留选中状态使用UDN作为唯一标识
Args:
new_devices: 搜索的设备
current_selected_device:当前选中的设备
"""
#当前选中设备
current_selected_udn = None
if current_selected_device:
current_selected_udn = current_selected_device.get('udn')
# 创建新设备字典以UDN为键
new_device_dict = {}
for device in new_devices:
device_udn = device.get('udn')
if device_udn: # 只有有UDN的设备才处理
new_device_dict[device_udn] = device
# 获取之前列表中所有设备的UDN
current_device_udn_list = []
for i in range(self.device_list.count()):
item = self.device_list.item(i)
device = item.data(Qt.UserRole)
device_udn = device.get('udn')
if device_udn:
current_device_udn_list.append(device_udn)
# 移除不存在的设备
items_to_remove = []
for i in range(self.device_list.count()):
item = self.device_list.item(i)
device = item.data(Qt.UserRole)
# 获取设备的标识优先使用UDN没有则用IP
device_udn = device.get('udn')
# 如果设备标识不在新设备列表中,标记为删除
if device_udn not in new_device_dict:
items_to_remove.append(i)
# 从后往前删除,避免索引变化
for index in reversed(items_to_remove):
self.device_list.takeItem(index)
# 如果删除的是当前选中的设备,清空选中状态
if item and item == self.device_list.currentItem():
self.device_list.clearSelection()
self.selected_device = None
# 更新或添加设备
current_selected_item = None
for device in new_devices:
# 获取设备标识优先使用UDN没有则用IP
device_identifier = device.get('udn')
device_name = device.get('friendly_name', '未知设备')
device_ip = device.get('ip', '未知IP')
# 创建显示文本
if device.get('udn'):
# 简化UDN显示只显示最后一部分
udn_parts = device_identifier.split(':')
udn_display = udn_parts[-1][:8] if len(udn_parts) > 1 else device_identifier[:8]
item_text = f"{device_name} [{device_ip}]"
else:
item_text = f"{device_name} [{device_ip}] (无UDN)"
# 检查设备是否已存在
found_item = None
for i in range(self.device_list.count()):
item = self.device_list.item(i)
existing_device = item.data(Qt.UserRole)
# 比较设备标识
existing_identifier = existing_device.get('udn')
if not existing_identifier:
existing_identifier = existing_device.get('ip', '')
if existing_identifier == device_identifier:
found_item = item
break
if found_item:
# 设备已存在,更新显示文本和设备数据
if found_item.text() != item_text:
found_item.setText(item_text)
# 更新设备数据(可能有新信息)
found_item.setData(Qt.UserRole, device)
# 检查是否是之前选中的设备
if current_selected_udn and device.get('udn') == current_selected_udn:
current_selected_item = found_item
else:
# 设备不存在,添加新项目
item = QListWidgetItem(item_text)
item.setData(Qt.UserRole, device)
self.device_list.addItem(item)
# 检查是否是之前选中的设备
if current_selected_udn and device.get('udn') == current_selected_udn:
current_selected_item = item
# 恢复选中状态
if current_selected_item:
self.device_list.setCurrentItem(current_selected_item)
self.selected_device = current_selected_item.data(Qt.UserRole)
device_name = self.selected_device.get('friendly_name', '未知设备')
self.log_message("设备选择", f"已恢复选择设备: {device_name}")
elif self.device_list.count() > 0 and self.device_list.currentItem() is None:
# 如果之前选中的设备不存在了,清空选中
self.device_list.clearSelection()
self.selected_device = None
elif self.device_list.count() == 0:
# 没有设备
self.selected_device = None
# 更新设备数量统计
device_count = self.device_list.count()
if device_count > 0:
self.log_message("设备发现", f"发现 {device_count} 个投屏设备")
# 如果有选中的设备,启用控制按钮
if self.selected_device:
device_name = self.selected_device.get('friendly_name', '未知设备')
self.log_message("设备选择", f"当前选中: {device_name}")
else:
self.log_message("设备发现", "未发现任何投屏设备", "warning")
self.selected_device = None
def select_device(self, item):
"""从列表中选择设备"""
device = item.data(Qt.UserRole)
if device:
self.selected_device = device
self.log_message("设备选择", f"已选择设备: {device.get('friendly_name', '未知')}")
# 启用控制按钮
self.start_btn.setText("开始投屏")
self.enable_control_buttons(True)
def browse_file(self):
"""浏览并选择媒体文件"""
file_dialog = QFileDialog()
file_dialog.setFileMode(QFileDialog.ExistingFile)
# 设置文件过滤器
file_dialog.setNameFilter(
"媒体文件 (*.mp4 *.avi *.mkv *.mov *.mp3 *.wav *.flac *.wmv *.flv *.webm "
"*.mpeg *.mpg *.m4v *.3gp *.m4a *.aac *.ogg *.wma *.ape *.alac "
"*.amr *.opus *.ac3 *.dts *.ts *.m2ts *.vob *.ogv *.asf *.rm "
"*.rmvb *.divx *.xvid *.f4v *.m2v *.mpe *.mp2 *.mp1 *.ra *.ram "
"*.mid *.midi *.cda *.gsm *.vox *.voc *.dvf *.raw *.mka *.tta "
"*.spx *.8svx *.aiff *.au *.snd *.paf *.svx *.nist *.ircam "
"*.voc *.smp *.vox *.sou);;"
)
if file_dialog.exec_():
files = file_dialog.selectedFiles()
if files:
self.selected_file = files[0]
file_name = os.path.basename(self.selected_file)
self.file_label.setText(file_name)
#选择文件后启用播放按钮
self.enable_control_buttons(True)
self.log_message("文件选择", f"已选择文件: {file_name}")
def start_casting(self):
"""开始投屏"""
if not self.selected_device :
self.log_message("投屏", "请先选择设备", "error")
return
if not self.selected_file:
self.log_message("投屏", "请先选择文件", "error")
return
try:
# 如果是第一次播放需要创建DLNA控制器并设置URI
if not self.dlna_controller:
# 创建DLNA控制器
self.dlna_controller = DLNAController(self.selected_device)
# 启动HTTP服务器并获取文件URL
file_name = os.path.basename(self.selected_file)
dir_bool = self.http_server.set_root_directory(self.selected_file)
self.log_message("设置服务路径", "成功" if dir_bool else "失败")
# 获取IP地址从IP标签中提取
ip_text = self.ip_label.text()
file_url = f"http://{ip_text}/{file_name}"
self.log_message("设置播放地址", file_url)
# 设置播放URI并开始播放
if self.dlna_controller.set_av_transport_uri(file_url, file_name):
self.dlna_controller.play()
self.start_btn.setEnabled(False)
self.pause_btn.setEnabled(True)
self.stop_btn.setEnabled(True)
self.log_message("投屏", f"开始投屏: {file_name}")
self.statusBar().showMessage(f"正在投屏: {file_name}")
self.is_paused = False # 确保不是暂停状态
else:
QMessageBox.critical(self, "错误", "投屏失败,请检查设备连接")
return
else:
# 如果已经创建了DLNA控制器可能是从暂停状态恢复
if self.is_paused:
# 从暂停状态恢复播放
if self.dlna_controller.play():
self.is_paused = False
self.start_btn.setEnabled(False)
self.pause_btn.setEnabled(True)
self.stop_btn.setEnabled(True)
self.log_message("投屏", "继续播放")
else:
QMessageBox.critical(self, "错误", "继续播放失败")
else:
# 如果不是暂停状态,说明是停止后的重新播放
# 需要重新设置URI
file_name = os.path.basename(self.selected_file)
ip_text = self.ip_label.text()
dir_bool = self.http_server.set_root_directory(self.selected_file)
self.log_message("设置服务路径", "成功" if dir_bool else "失败")
file_url = f"http://{ip_text}/{file_name}"
if self.dlna_controller.set_av_transport_uri(file_url, file_name):
self.dlna_controller.play()
self.start_btn.setEnabled(False)
self.pause_btn.setEnabled(True)
self.stop_btn.setEnabled(True)
self.log_message("投屏", f"重新开始投屏: {file_name}")
self.statusBar().showMessage(f"正在投屏: {file_name}")
else:
QMessageBox.critical(self, "错误", "重新投屏失败")
except Exception as e:
QMessageBox.critical(self, "错误", f"投屏时出错: {str(e)}")
self.log_message("投屏", f"投屏失败: {str(e)}", "error")
def pause_casting(self):
"""暂停投屏"""
if self.dlna_controller:
self.dlna_controller.pause()
self.is_paused = True # 标记为暂停状态
self.start_btn.setText("继续播放")
self.start_btn.setEnabled(True)
self.pause_btn.setEnabled(False)
self.log_message("投屏", "已暂停播放")
def stop_casting(self):
"""停止投屏"""
if self.dlna_controller:
self.dlna_controller.stop()
self.is_paused = False # 重置暂停状态
self.start_btn.setText("开始投屏")
self.start_btn.setEnabled(True)
self.pause_btn.setEnabled(False)
self.stop_btn.setEnabled(False)
self.log_message("投屏", "已停止播放")
def volume_changed(self, value):
"""音量改变"""
if self.dlna_controller:
self.dlna_controller.set_volume(value)
def toggle_mute(self, checked):
"""切换静音"""
if self.dlna_controller:
self.dlna_controller.set_mute(checked)
icon = "🔇" if checked else "🔊"
self.mute_btn.setText(icon)
status = "静音" if checked else "取消静音"
self.log_message("控制", f"{status}")
def log_message(self, category, message, level="info"):
"""记录日志消息"""
# timestamp = datetime.now().strftime("%H:%M:%S")
#
# if level == "error":
# color = "#dc3545"
# prefix = "❌"
# elif level == "warning":
# color = "#ffc107"
# prefix = "⚠️"
# else:
# color = "#0d6efd"
# prefix = ""
#
# log_entry = f'<span style="color:#adb5bd;">[{timestamp}]</span><span style="color:{color};"><b>{category}:</b> {prefix} {message}</span><br>'
#
# self.log_text.insertHtml(log_entry)
#
# # 自动滚动到底部
# scrollbar = self.log_text.verticalScrollBar()
# scrollbar.setValue(scrollbar.maximum())
self.logger.info(message)
# 同时输出到状态栏
if level == "error":
self.statusBar().showMessage(f"错误: {message}", 1000)
elif level == "warning":
self.statusBar().showMessage(f"警告: {message}", 1000)
else:
self.statusBar().showMessage(message, 1000)
def enable_control_buttons(self, enabled):
"""启用/禁用控制按钮"""
if self.selected_device and self.selected_file:
self.start_btn.setEnabled(True)
self.pause_btn.setEnabled(False)
self.stop_btn.setEnabled(False)
self.volume_slider.setEnabled(enabled)
self.mute_btn.setEnabled(enabled)
def closeEvent(self, event):
"""窗口关闭事件"""
# 停止HTTP服务器
if self.http_server:
self.http_server.stop()
# 停止DLNA控制器
if self.dlna_controller:
try:
self.dlna_controller.stop()
except:
pass
self.log_message("系统", "应用程序已关闭")
event.accept()