Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
235eac664f | ||
|
|
e66c610b65 | ||
|
|
7f75aaac10 | ||
|
|
caeb586536 | ||
|
|
d8d1cbdc78 | ||
|
|
d72408cf62 | ||
|
|
ef6737634f | ||
|
|
d7d8d21384 | ||
|
|
3639be1d31 | ||
|
|
ab3edaeae1 | ||
|
|
9eb3427728 | ||
|
|
f8f0eb0935 | ||
|
|
5b03aaa8df | ||
|
|
d92f6d6cb5 | ||
|
|
e2ec8d6760 | ||
|
|
4b8125e729 | ||
|
|
d76ce24954 |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -1 +1,3 @@
|
||||
/.idea
|
||||
/build
|
||||
/.venv
|
||||
|
||||
201
LICENSE
Normal file
201
LICENSE
Normal 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.
|
||||
15
README.md
15
README.md
@@ -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
|
||||
|
||||
@@ -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为location,value为(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))
|
||||
|
||||
@@ -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('&', '&').replace('<', '<').replace('>', '>').replace('"', '"')
|
||||
# 构建简单的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('&', '&').replace('<', '<').replace('>', '>')
|
||||
media_url_escaped = urllib.parse.quote(media_url_escaped,safe=':/')
|
||||
metadata_escaped = metadata.replace('&', '&').replace('<', '<').replace('>', '>')
|
||||
|
||||
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('&', '&').replace('<', '<').replace('>', '>')
|
||||
|
||||
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
|
||||
@@ -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
BIN
icons/icon.icns
Normal file
Binary file not shown.
|
Before Width: | Height: | Size: 104 KiB After Width: | Height: | Size: 104 KiB |
BIN
icons/icon.png
Normal file
BIN
icons/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.3 KiB |
7231
resources.py
Normal file
7231
resources.py
Normal file
File diff suppressed because it is too large
Load Diff
8
resources.qrc
Normal file
8
resources.qrc
Normal 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>
|
||||
@@ -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):
|
||||
"""窗口关闭事件"""
|
||||
|
||||
Reference in New Issue
Block a user