17 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
yqsphp
d7d8d21384 Merge remote-tracking branch 'origin/master' 2026-01-12 14:34:59 +08:00
yqsphp
3639be1d31 修复:选择媒体后更新文件服务 2026-01-12 14:34:45 +08:00
yqsphp
ab3edaeae1 add LICENSE.
Signed-off-by: yqsphp <yqsphp@qq.com>
2026-01-12 06:08:58 +00:00
yqsphp
9eb3427728 修复多网络请求只根据本机内网ip所在网络搜索设备 2026-01-12 13:21:06 +08:00
yqsphp
f8f0eb0935 修复多网络请求只根据本机内网ip所在网络搜索设备 2026-01-12 13:18:55 +08:00
yqsphp
5b03aaa8df update README.md.
Signed-off-by: yqsphp <yqsphp@qq.com>
2026-01-12 03:14:28 +00:00
yqsphp
d92f6d6cb5 update README.md.
Signed-off-by: yqsphp <yqsphp@qq.com>
2026-01-12 03:13:39 +00:00
yqsphp
e2ec8d6760 update README.md.
Signed-off-by: yqsphp <yqsphp@qq.com>
2026-01-12 03:12:59 +00:00
yqsphp
4b8125e729 update README.md.
Signed-off-by: yqsphp <yqsphp@qq.com>
2026-01-12 03:12:13 +00:00
yqsphp
d76ce24954 readme更新 2026-01-12 11:03:54 +08:00
13 changed files with 7600 additions and 320 deletions

2
.gitignore vendored
View File

@@ -1 +1,3 @@
/.idea
/build
/.venv

201
LICENSE Normal file
View File

@@ -0,0 +1,201 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

View File

@@ -1,7 +1,10 @@
# 媒体投屏器 (Media Caster)
## 程序由来
本来想着在网上找个能在window客户运行的媒体投屏软件来把电脑上的媒体投屏到投影仪上
但找了多款都是必须要双端安装,还需要验证码等,点对点投屏,无语。安卓端却有一堆投屏软件 :sweat_smile:
然后自己通过AI辅助摸索着写了这个程序。自己也足够用了
<p align="center">
<img src="docs/images/logo.png" alt="媒体投屏器 Logo" width="200">
<img src="%E9%A2%84%E8%A7%88%E5%9B%BE.png" />
<br>
<em>一个优雅、高效的本地媒体投屏工具</em>
</p>
@@ -20,7 +23,6 @@
<img src="https://img.shields.io/badge/License-MIT-yellow.svg" alt="许可证">
<img src="https://img.shields.io/badge/Platform-Windows%20%7C%20macOS%20%7C%20Linux-lightgrey.svg" alt="平台支持">
</p>
## 🌟 简介
媒体投屏器是一款基于 Python 和 PyQt5 开发的高性能媒体投屏工具,支持将本地音视频文件通过 DLNA/UPnP 协议投屏到智能电视、投影仪等设备。
@@ -39,7 +41,6 @@
| **文件浏览** | 支持多种音视频格式选择 | ✅ |
| **投屏控制** | 播放、暂停、停止、音量控制 | ✅ |
| **音量调节** | 滑块控制、静音切换 | ✅ |
| **多设备支持** | 同时发现和管理多个设备 | ✅ |
### 🖥️ 系统要求
@@ -51,12 +52,10 @@
## 🚀 快速开始
### 安装方法
#### 方法一:从源代码安装(推荐)
```bash
# 1. 克隆仓库
git clone https://github.com/yqsphp/media-caster.git
cd media-caster
git clone https://gitee.com/yqsphp/MediaCast.git
cd MediaCast
# 2. 创建虚拟环境
python -m venv venv

View File

@@ -30,21 +30,18 @@ class DeviceDiscovery:
Returns:
list: 发现的设备列表
"""
# 首先检查网络连接
try:
test_sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
test_sock.settimeout(1)
test_sock.sendto(b'test', ('239.255.255.250', 1900))
test_sock.recvfrom(1024)
test_sock.close()
except socket.timeout:
self.logger.warning("网络可能没有响应,检查防火墙设置")
except Exception as e:
self.logger.warning(f"网络测试异常: {e}")
devices_by_uuid = {} # 用UUID去重
pending_devices = {} # 待处理的设备key为locationvalue为(ip, headers)
# 获取本机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 = "获取失败"
# SSDP 发现消息
ssdp_msg = (
"M-SEARCH * HTTP/1.1\r\n"
@@ -60,7 +57,8 @@ class DeviceDiscovery:
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP)
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
sock.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_TTL, 2)
sock.bind(('0.0.0.0', 0))
# 绑定本机ip
sock.bind((local_ip, 0))
# 发送发现请求
sock.sendto(ssdp_msg.encode(), ('239.255.255.250', 1900))

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

View File

@@ -441,7 +441,7 @@ class StreamingFileServer:
self.httpd: Optional[StreamingHTTPServer] = None
self.server_thread: Optional[threading.Thread] = None
self.is_running = False
self.logger = AppLogger.create_default_logger('StreamingFileServer')
self.logger = AppLogger('StreamingFileServer')
# 初始化MIME类型
mimetypes.init()
@@ -617,8 +617,7 @@ class StreamingFileServer:
class HTTPFileServer(StreamingFileServer):
"""高级流式服务器,支持更多功能"""
def __init__(self, port: int = 8000, bind_address: str = "0.0.0.0",
max_chunk_size: int = 1024 * 256): # 256KB
def __init__(self, port: int = 8000, bind_address: str = "0.0.0.0", max_chunk_size: int = 1024 * 256): # 256KB
"""
初始化高级流式服务器

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.1"
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("""
/* 主窗口 */
@@ -140,11 +162,6 @@ class MainWindow(QMainWindow):
color: white;
background-color: #6c757d;
}
QPushButton:disabled {
color: #999;
background-color: #f5f5f5;
}
/* 特殊按钮样式 */
#browseBtn{
background:white;
@@ -175,6 +192,14 @@ class MainWindow(QMainWindow):
color: white;
border: none;
}
#startCastBtn:disabled,
#pauseCastBtn:disabled,
#stopCastBtn:disabled{
color: #999 !important;
background-color: #f5f5f5 !important;
}
/*音量图标*/
#muteBtn{
background-color:none;
@@ -222,7 +247,7 @@ class MainWindow(QMainWindow):
}
""")
def create_left_panel(self):
def create_panel(self):
"""创建左侧控制面板(文件和设备控制合并)"""
panel = QWidget()
layout = QVBoxLayout(panel)
@@ -297,13 +322,13 @@ class MainWindow(QMainWindow):
# 设备列表标题和刷新按钮
device_header = QHBoxLayout()
device_header.addWidget(QLabel("可用设备列表:"))
device_header.addWidget(QLabel("设备列表(单击选中):"))
device_header.addStretch()
device_layout.addLayout(device_header)
# 设备列表
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)
@@ -322,6 +347,7 @@ class MainWindow(QMainWindow):
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("暂停投屏")
@@ -360,6 +386,7 @@ class MainWindow(QMainWindow):
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)
@@ -371,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):
"""初始化信号连接"""
# 设备发现
@@ -495,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:
@@ -595,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)
@@ -606,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:
@@ -631,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:
@@ -648,6 +589,7 @@ class MainWindow(QMainWindow):
self.log_message("设备选择", f"已选择设备: {device.get('friendly_name', '未知')}")
# 启用控制按钮
self.start_btn.setText("开始投屏")
self.enable_control_buttons(True)
def browse_file(self):
@@ -673,7 +615,7 @@ class MainWindow(QMainWindow):
file_name = os.path.basename(self.selected_file)
self.file_label.setText(file_name)
#选择文件后启用播放按钮
self.start_btn.setEnabled(True)
self.enable_control_buttons(True)
self.log_message("文件选择", f"已选择文件: {file_name}")
def start_casting(self):
@@ -701,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)
@@ -730,9 +672,11 @@ class MainWindow(QMainWindow):
# 需要重新设置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):
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)
@@ -750,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):
"""音量改变"""
@@ -808,7 +752,7 @@ class MainWindow(QMainWindow):
# # 自动滚动到底部
# scrollbar = self.log_text.verticalScrollBar()
# scrollbar.setValue(scrollbar.maximum())
self.logger.info(message)
# 同时输出到状态栏
if level == "error":
self.statusBar().showMessage(f"错误: {message}", 1000)
@@ -819,10 +763,12 @@ class MainWindow(QMainWindow):
def enable_control_buttons(self, enabled):
"""启用/禁用控制按钮"""
self.pause_btn.setEnabled(False)
self.stop_btn.setEnabled(False)
self.volume_slider.setEnabled(enabled)
self.mute_btn.setEnabled(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):
"""窗口关闭事件"""

BIN
预览图.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB