7 Commits

Author SHA1 Message Date
yqsphp
235eac664f 简化代码,解决程序图标问题 2026-01-13 15:23:26 +08:00
yqsphp
e66c610b65 简化代码,解决程序图标问题 2026-01-13 15:23:02 +08:00
yqsphp
7f75aaac10 Merge remote-tracking branch 'origin/master' 2026-01-13 12:57:08 +08:00
yqsphp
caeb586536 预览图更新 2026-01-13 12:56:48 +08:00
yqsphp
d8d1cbdc78 update README.md.
Signed-off-by: yqsphp <yqsphp@qq.com>
2026-01-13 01:29:43 +00:00
yqsphp
d72408cf62 readme更新 2026-01-12 18:02:18 +08:00
yqsphp
ef6737634f 优化代码,新增投屏是推送文件名称 2026-01-12 17:00:18 +08:00
9 changed files with 7359 additions and 285 deletions

View File

@@ -1,5 +1,8 @@
# 媒体投屏器 (Media Caster)
## 程序由来
本来想着在网上找个能在window客户运行的媒体投屏软件来把电脑上的媒体投屏到投影仪上
但找了多款都是必须要双端安装,还需要验证码等,点对点投屏,无语。安卓端却有一堆投屏软件 :sweat_smile:
然后自己通过AI辅助摸索着写了这个程序。自己也足够用了
<p align="center">
<img src="%E9%A2%84%E8%A7%88%E5%9B%BE.png" />
<br>

View File

@@ -132,12 +132,13 @@ class DLNAController:
# ================== AVTransport 服务方法 ==================
def set_av_transport_uri(self, media_url, metadata=""):
def set_av_transport_uri(self, media_url, title = "", metadata=""):
"""
设置播放URI
Args:
media_url: 媒体文件的URL
title: 媒体文件名称
metadata: 媒体元数据(可选)
Returns:
@@ -146,16 +147,27 @@ class DLNAController:
if not self.av_transport_url:
return False
if title and not metadata:
# 转义title中的XML特殊字符
title_escaped = title.replace('&', '&amp;').replace('<', '&lt;').replace('>', '&gt;').replace('"', '&quot;')
# 构建简单的DIDL-Lite元数据
metadata = f"""<DIDL-Lite xmlns="urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/"
xmlns:dc="http://purl.org/dc/elements/1.1/">
<item id="1" parentID="0" restricted="1">
<dc:title>{title_escaped}</dc:title>
</item>
</DIDL-Lite>"""
# 转义XML特殊字符
media_url_escaped = media_url.replace('&', '&amp;').replace('<', '&lt;').replace('>', '&gt;')
media_url_escaped = urllib.parse.quote(media_url_escaped,safe=':/')
metadata_escaped = metadata.replace('&', '&amp;').replace('<', '&lt;').replace('>', '&gt;')
soap_body = f"""<u:SetAVTransportURI xmlns:u="urn:schemas-upnp-org:service:AVTransport:1">
<InstanceID>0</InstanceID>
<CurrentURI>{media_url_escaped}</CurrentURI>
<CurrentURIMetaData>{metadata_escaped}</CurrentURIMetaData>
</u:SetAVTransportURI>"""
<InstanceID>0</InstanceID>
<CurrentURI>{media_url_escaped}</CurrentURI>
<CurrentURIMetaData>{metadata_escaped}</CurrentURIMetaData>
</u:SetAVTransportURI>"""
action = "urn:schemas-upnp-org:service:AVTransport:1#SetAVTransportURI"
success, response = self._send_soap_request(self.av_transport_url, action, soap_body)
@@ -178,9 +190,9 @@ class DLNAController:
return False
soap_body = f"""<u:Play xmlns:u="urn:schemas-upnp-org:service:AVTransport:1">
<InstanceID>0</InstanceID>
<Speed>{speed}</Speed>
</u:Play>"""
<InstanceID>0</InstanceID>
<Speed>{speed}</Speed>
</u:Play>"""
action = "urn:schemas-upnp-org:service:AVTransport:1#Play"
success, response = self._send_soap_request(self.av_transport_url, action, soap_body)
@@ -198,8 +210,8 @@ class DLNAController:
return False
soap_body = """<u:Pause xmlns:u="urn:schemas-upnp-org:service:AVTransport:1">
<InstanceID>0</InstanceID>
</u:Pause>"""
<InstanceID>0</InstanceID>
</u:Pause>"""
action = "urn:schemas-upnp-org:service:AVTransport:1#Pause"
success, response = self._send_soap_request(self.av_transport_url, action, soap_body)
@@ -217,8 +229,8 @@ class DLNAController:
return False
soap_body = """<u:Stop xmlns:u="urn:schemas-upnp-org:service:AVTransport:1">
<InstanceID>0</InstanceID>
</u:Stop>"""
<InstanceID>0</InstanceID>
</u:Stop>"""
action = "urn:schemas-upnp-org:service:AVTransport:1#Stop"
success, response = self._send_soap_request(self.av_transport_url, action, soap_body)
@@ -242,10 +254,10 @@ class DLNAController:
target_escaped = target.replace('&', '&amp;').replace('<', '&lt;').replace('>', '&gt;')
soap_body = f"""<u:Seek xmlns:u="urn:schemas-upnp-org:service:AVTransport:1">
<InstanceID>0</InstanceID>
<Unit>{unit}</Unit>
<Target>{target_escaped}</Target>
</u:Seek>"""
<InstanceID>0</InstanceID>
<Unit>{unit}</Unit>
<Target>{target_escaped}</Target>
</u:Seek>"""
action = "urn:schemas-upnp-org:service:AVTransport:1#Seek"
success, response = self._send_soap_request(self.av_transport_url, action, soap_body)
@@ -267,8 +279,8 @@ class DLNAController:
return None
soap_body = """<u:GetTransportInfo xmlns:u="urn:schemas-upnp-org:service:AVTransport:1">
<InstanceID>0</InstanceID>
</u:GetTransportInfo>"""
<InstanceID>0</InstanceID>
</u:GetTransportInfo>"""
action = "urn:schemas-upnp-org:service:AVTransport:1#GetTransportInfo"
success, response = self._send_soap_request(self.av_transport_url, action, soap_body)
@@ -321,8 +333,8 @@ class DLNAController:
return None
soap_body = """<u:GetPositionInfo xmlns:u="urn:schemas-upnp-org:service:AVTransport:1">
<InstanceID>0</InstanceID>
</u:GetPositionInfo>"""
<InstanceID>0</InstanceID>
</u:GetPositionInfo>"""
action = "urn:schemas-upnp-org:service:AVTransport:1#GetPositionInfo"
success, response = self._send_soap_request(self.av_transport_url, action, soap_body)
@@ -375,10 +387,10 @@ class DLNAController:
volume = max(0, min(100, int(volume)))
soap_body = f"""<u:SetVolume xmlns:u="urn:schemas-upnp-org:service:RenderingControl:1">
<InstanceID>0</InstanceID>
<Channel>{channel}</Channel>
<DesiredVolume>{volume}</DesiredVolume>
</u:SetVolume>"""
<InstanceID>0</InstanceID>
<Channel>{channel}</Channel>
<DesiredVolume>{volume}</DesiredVolume>
</u:SetVolume>"""
action = "urn:schemas-upnp-org:service:RenderingControl:1#SetVolume"
success, response = self._send_soap_request(self.rendering_control_url, action, soap_body)
@@ -399,9 +411,9 @@ class DLNAController:
return None
soap_body = f"""<u:GetVolume xmlns:u="urn:schemas-upnp-org:service:RenderingControl:1">
<InstanceID>0</InstanceID>
<Channel>{channel}</Channel>
</u:GetVolume>"""
<InstanceID>0</InstanceID>
<Channel>{channel}</Channel>
</u:GetVolume>"""
action = "urn:schemas-upnp-org:service:RenderingControl:1#GetVolume"
success, response = self._send_soap_request(self.rendering_control_url, action, soap_body)
@@ -444,10 +456,10 @@ class DLNAController:
desired_mute = "1" if mute else "0"
soap_body = f"""<u:SetMute xmlns:u="urn:schemas-upnp-org:service:RenderingControl:1">
<InstanceID>0</InstanceID>
<Channel>{channel}</Channel>
<DesiredMute>{desired_mute}</DesiredMute>
</u:SetMute>"""
<InstanceID>0</InstanceID>
<Channel>{channel}</Channel>
<DesiredMute>{desired_mute}</DesiredMute>
</u:SetMute>"""
action = "urn:schemas-upnp-org:service:RenderingControl:1#SetMute"
success, response = self._send_soap_request(self.rendering_control_url, action, soap_body)
@@ -468,9 +480,9 @@ class DLNAController:
return None
soap_body = f"""<u:GetMute xmlns:u="urn:schemas-upnp-org:service:RenderingControl:1">
<InstanceID>0</InstanceID>
<Channel>{channel}</Channel>
</u:GetMute>"""
<InstanceID>0</InstanceID>
<Channel>{channel}</Channel>
</u:GetMute>"""
action = "urn:schemas-upnp-org:service:RenderingControl:1#GetMute"
success, response = self._send_soap_request(self.rendering_control_url, action, soap_body)
@@ -512,7 +524,7 @@ class DLNAController:
return None
soap_body = """<u:GetProtocolInfo xmlns:u="urn:schemas-upnp-org:service:ConnectionManager:1">
</u:GetProtocolInfo>"""
</u:GetProtocolInfo>"""
action = "urn:schemas-upnp-org:service:ConnectionManager:1#GetProtocolInfo"
success, response = self._send_soap_request(self.connection_manager_url, action, soap_body)
@@ -540,119 +552,3 @@ class DLNAController:
except ET.ParseError:
return None
# ================== 高级方法 ==================
def play_media(self, media_url, metadata="", wait=1):
"""
播放媒体文件(组合操作)
Args:
media_url: 媒体文件URL
metadata: 媒体元数据
wait: 设置URI后的等待时间
Returns:
bool: 是否成功
"""
import time
if self.set_av_transport_uri(media_url, metadata):
time.sleep(wait)
return self.play()
return False
def get_device_status(self):
"""
获取设备完整状态
Returns:
dict: 设备状态信息
"""
status = {
'device_info': {
'friendly_name': self.device_info.get('friendly_name', '未知'),
'ip': self.device_info.get('ip', '未知'),
'location': self.device_info.get('location', '未知')
},
'transport_info': self.get_transport_info(),
'position_info': self.get_position_info(),
'volume': self.get_volume(),
'mute': self.get_mute(),
'protocol_info': self.get_protocol_info(),
'services': {
'av_transport': self.av_transport_url is not None,
'rendering_control': self.rendering_control_url is not None,
'connection_manager': self.connection_manager_url is not None
}
}
return status
def get_supported_formats(self):
"""
获取设备支持的媒体格式
Returns:
list: 支持的格式列表
"""
protocol_info = self.get_protocol_info()
if not protocol_info or 'sink' not in protocol_info:
return []
# 解析sink字段提取支持的格式
sink_info = protocol_info['sink']
formats = []
# DLNA格式通常是逗号分隔的
for fmt in sink_info.split(','):
fmt = fmt.strip()
if fmt:
formats.append(fmt)
return formats
def can_play_format(self, media_url):
"""
检查设备是否可能支持指定格式
Args:
media_url: 媒体URL
Returns:
bool: 是否可能支持
"""
import mimetypes
# 获取文件扩展名
if '.' in media_url:
ext = media_url.split('.')[-1].lower()
# 常见扩展名到MIME类型的映射
ext_to_mime = {
'mp4': 'video/mp4',
'mp3': 'audio/mp3',
'm4a': 'audio/mp4',
'wav': 'audio/wav',
'flac': 'audio/flac',
'mkv': 'video/x-matroska',
'avi': 'video/x-msvideo',
'mov': 'video/quicktime',
'wmv': 'video/x-ms-wmv',
'flv': 'video/x-flv',
'webm': 'video/webm',
'ogg': 'audio/ogg',
'oga': 'audio/ogg',
'ogv': 'video/ogg',
'm3u8': 'application/x-mpegURL',
'mpd': 'application/dash+xml'
}
mime_type = ext_to_mime.get(ext)
if mime_type:
# 检查设备是否支持该MIME类型
supported_formats = self.get_supported_formats()
for fmt in supported_formats:
if mime_type in fmt:
return True
return False

BIN
icons/icon.icns Normal file

Binary file not shown.

View File

Before

Width:  |  Height:  |  Size: 104 KiB

After

Width:  |  Height:  |  Size: 104 KiB

BIN
icons/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

7231
resources.py Normal file

File diff suppressed because it is too large Load Diff

8
resources.qrc Normal file
View File

@@ -0,0 +1,8 @@
<!DOCTYPE RCC>
<RCC version="1.0">
<qresource>
<file>icons/icon.ico</file>
<file>icons/icon.png</file>
<file>icons/icon.icns</file>
</qresource>
</RCC>

View File

@@ -2,18 +2,56 @@ import sys
import os
import threading
import socket
from datetime import datetime
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, QTextEdit, QFileDialog, QMessageBox, QListWidgetItem
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):
"""智能媒体投屏器主窗口"""
@@ -34,25 +72,20 @@ class MainWindow(QMainWindow):
def init_ui(self):
"""初始化UI界面"""
version = "v1.0.2"
version = "v1.0.3"
app_name = "多媒体投屏 by yqsphp"
icon = "icon.ico"
self.setWindowTitle(app_name)
self.setGeometry(100, 100, 700, 500)
# 获取图标路径(支持打包和开发环境)
if getattr(sys, 'frozen', False):
# 打包后的exe环境
base_path = sys._MEIPASS
else:
# 开发环境
base_path = os.path.abspath(".")
# 1. 加载主图标
icon = IconManager.get_icon()
icon_path = os.path.join(base_path, icon)
# 2. 设置窗口图标(标题栏)
self.setWindowIcon(icon)
# 设置应用程序图标(影响任务栏)
self.setWindowIcon(QIcon(icon_path))
# 3. 设置应用程序图标(任务栏)
QApplication.instance().setWindowIcon(icon)
# Windows专用设置AppUserModelID非常重要
if sys.platform == "win32":
@@ -67,33 +100,23 @@ class MainWindow(QMainWindow):
# 创建中央部件
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_left_panel()
left_panel = self.create_panel()
horizontal_layout.addWidget(left_panel, 1)
# 右侧信息面板
# right_panel = self.create_right_panel()
# horizontal_layout.addWidget(right_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)
@@ -105,7 +128,6 @@ class MainWindow(QMainWindow):
margin-top: 10px;
""")
main_layout.addWidget(copyright_label)
# 设置扁平化样式
self.setStyleSheet("""
/* 主窗口 */
@@ -225,7 +247,7 @@ class MainWindow(QMainWindow):
}
""")
def create_left_panel(self):
def create_panel(self):
"""创建左侧控制面板(文件和设备控制合并)"""
panel = QWidget()
layout = QVBoxLayout(panel)
@@ -306,7 +328,7 @@ class MainWindow(QMainWindow):
# 设备列表
self.device_list = QListWidget()
self.device_list.itemClicked.connect(self.select_device_from_list)
self.device_list.itemClicked.connect(self.select_device)
self.device_list.setMinimumHeight(100)
device_layout.addWidget(self.device_list)
@@ -376,84 +398,6 @@ class MainWindow(QMainWindow):
layout.addStretch()
return panel
def create_right_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)
# 文件服务状态
service_frame = QFrame()
service_layout = QHBoxLayout(service_frame)
service_layout.addWidget(QLabel("文件服务:"))
self.file_service_label = QLabel("就绪")
self.file_service_label.setStyleSheet("color: #28a745;")
service_layout.addWidget(self.file_service_label, 1)
system_layout.addWidget(service_frame)
system_group.setLayout(system_layout)
layout.addWidget(system_group)
# ==================== 操作日志区域 ====================
log_group = QGroupBox("操作日志")
log_layout = QVBoxLayout()
log_layout.setSpacing(2)
self.log_text = QTextEdit()
self.log_text.setReadOnly(True)
self.log_text.setMinimumHeight(250)
# 添加初始日志
self.log_message("系统", "智能媒体投屏器已启动")
self.log_message("系统", f"本地IP地址: {local_ip}")
log_layout.addWidget(self.log_text, 1)
# 日志控制按钮
log_btn_layout = QHBoxLayout()
log_btn_layout.setSpacing(8)
clear_log_btn = QPushButton("清空日志")
clear_log_btn.clicked.connect(self.log_text.clear)
log_btn_layout.addWidget(clear_log_btn)
log_btn_layout.addStretch()
log_layout.addLayout(log_btn_layout)
log_group.setLayout(log_layout)
layout.addWidget(log_group, 1) # 日志区域占据更多空间
layout.addStretch()
return panel
def init_connections(self):
"""初始化信号连接"""
# 设备发现
@@ -500,15 +444,13 @@ class MainWindow(QMainWindow):
self.device_discovery = DeviceDiscovery()
# 获取刷新后设备信息
new_device = self.device_discovery.discover_media_renderers()
self.update_device_list(new_device, self.selected_device)
# 搜索完成后启用按钮
self.enable_refresh_button()
self.update_device(new_device, self.selected_device)
# 确保在UI线程中更新
thread = threading.Thread(target=do_quick_discovery, daemon=True)
thread.start()
def update_device_list(self, new_devices, current_selected_device=None):
def update_device(self, new_devices, current_selected_device=None):
"""
更新设备列表显示保留选中状态使用UDN作为唯一标识
Args:
@@ -600,8 +542,6 @@ class MainWindow(QMainWindow):
# 检查是否是之前选中的设备
if current_selected_udn and device.get('udn') == current_selected_udn:
current_selected_item = found_item
elif current_selected_device and device.get('ip') == current_selected_device.get('ip'):
current_selected_item = found_item
else:
# 设备不存在,添加新项目
item = QListWidgetItem(item_text)
@@ -611,8 +551,6 @@ class MainWindow(QMainWindow):
# 检查是否是之前选中的设备
if current_selected_udn and device.get('udn') == current_selected_udn:
current_selected_item = item
elif current_selected_device and device.get('ip') == current_selected_device.get('ip'):
current_selected_item = item
# 恢复选中状态
if current_selected_item:
@@ -636,15 +574,13 @@ class MainWindow(QMainWindow):
# 如果有选中的设备,启用控制按钮
if self.selected_device:
self.enable_control_buttons(True)
device_name = self.selected_device.get('friendly_name', '未知设备')
self.log_message("设备选择", f"当前选中: {device_name}")
else:
self.log_message("设备发现", "未发现任何投屏设备", "warning")
self.selected_device = None
self.enable_control_buttons(False)
def select_device_from_list(self, item):
def select_device(self, item):
"""从列表中选择设备"""
device = item.data(Qt.UserRole)
if device:
@@ -707,7 +643,7 @@ class MainWindow(QMainWindow):
self.log_message("设置播放地址", file_url)
# 设置播放URI并开始播放
if self.dlna_controller.set_av_transport_uri(file_url):
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)
@@ -740,7 +676,7 @@ class MainWindow(QMainWindow):
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):
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)
@@ -758,27 +694,27 @@ class MainWindow(QMainWindow):
def pause_casting(self):
"""暂停投屏"""
if self.dlna_controller:
if self.dlna_controller.pause():
self.is_paused = True # 标记为暂停状态
self.dlna_controller.pause()
self.is_paused = True # 标记为暂停状态
self.start_btn.setText("继续播放")
self.start_btn.setEnabled(True)
self.pause_btn.setEnabled(False)
self.start_btn.setText("继续播放")
self.start_btn.setEnabled(True)
self.pause_btn.setEnabled(False)
self.log_message("投屏", "已暂停播放")
self.log_message("投屏", "已暂停播放")
def stop_casting(self):
"""停止投屏"""
if self.dlna_controller:
if self.dlna_controller.stop():
self.is_paused = False # 重置暂停状态
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.start_btn.setText("开始投屏")
self.start_btn.setEnabled(True)
self.pause_btn.setEnabled(False)
self.stop_btn.setEnabled(False)
self.log_message("投屏", "已停止播放")
self.log_message("投屏", "已停止播放")
def volume_changed(self, value):
"""音量改变"""

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 15 KiB