439 lines
16 KiB
Python
439 lines
16 KiB
Python
|
|
import subprocess
|
|||
|
|
import psutil
|
|||
|
|
import os
|
|||
|
|
import sys
|
|||
|
|
import json
|
|||
|
|
import socket
|
|||
|
|
import time
|
|||
|
|
import re
|
|||
|
|
from loguru import logger
|
|||
|
|
from PySide6.QtWidgets import (QApplication, QMainWindow, QPushButton, QMessageBox,
|
|||
|
|
QVBoxLayout, QWidget, QLabel, QGroupBox, QGridLayout,
|
|||
|
|
QStatusBar, QFrame, QDialog, QLineEdit, QFormLayout,
|
|||
|
|
QDialogButtonBox, QFileDialog, QHBoxLayout, QComboBox,
|
|||
|
|
QSpinBox, QCheckBox)
|
|||
|
|
from PySide6.QtCore import Qt, QThread, Signal
|
|||
|
|
from PySide6.QtGui import QFont
|
|||
|
|
|
|||
|
|
logger.add(sys.stderr, format="{time:YYYY-MM-DD HH:mm:ss} | {level} | {message}", level="INFO")
|
|||
|
|
|
|||
|
|
CONFIG_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "dual_screen_config.json")
|
|||
|
|
|
|||
|
|
def load_config():
|
|||
|
|
if os.path.exists(CONFIG_FILE):
|
|||
|
|
with open(CONFIG_FILE, 'r', encoding='utf-8') as f:
|
|||
|
|
return json.load(f)
|
|||
|
|
return {
|
|||
|
|
"server_ip": "192.168.1.100",
|
|||
|
|
"mediamtx_path": r"D:\ScreenCast\mediamtx\mediamtx.exe",
|
|||
|
|
"ffmpeg_path": r"D:\ScreenCast\ffmpeg\bin\ffmpeg.exe",
|
|||
|
|
"front_stream_path": "front",
|
|||
|
|
"back_stream_path": "back"
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
def save_config(config):
|
|||
|
|
with open(CONFIG_FILE, 'w', encoding='utf-8') as f:
|
|||
|
|
json.dump(config, f, ensure_ascii=False, indent=4)
|
|||
|
|
logger.info(f"配置已保存到 {CONFIG_FILE}")
|
|||
|
|
|
|||
|
|
def get_displays():
|
|||
|
|
displays = []
|
|||
|
|
try:
|
|||
|
|
result = subprocess.run(
|
|||
|
|
['powershell', '-Command',
|
|||
|
|
'Add-Type -AssemblyName System.Windows.Forms; '
|
|||
|
|
'[System.Windows.Forms.Screen]::AllScreens | ForEach-Object { '
|
|||
|
|
'$dev = $_.DeviceName; '
|
|||
|
|
'$w = $_.Bounds.Width; '
|
|||
|
|
'$h = $_.Bounds.Height; '
|
|||
|
|
'$x = $_.Bounds.X; '
|
|||
|
|
'$y = $_.Bounds.Y; '
|
|||
|
|
'$isPrimary = $_.Primary; '
|
|||
|
|
'Write-Output "$dev|$w|$h|$x|$y|$isPrimary" }'],
|
|||
|
|
capture_output=True, text=True, timeout=10
|
|||
|
|
)
|
|||
|
|
for line in result.stdout.strip().split('\n'):
|
|||
|
|
if line:
|
|||
|
|
parts = line.split('|')
|
|||
|
|
if len(parts) >= 6:
|
|||
|
|
displays.append({
|
|||
|
|
'name': parts[0],
|
|||
|
|
'width': int(parts[1]),
|
|||
|
|
'height': int(parts[2]),
|
|||
|
|
'x': int(parts[3]),
|
|||
|
|
'y': int(parts[4]),
|
|||
|
|
'primary': parts[5] == 'True'
|
|||
|
|
})
|
|||
|
|
except Exception as e:
|
|||
|
|
logger.error(f"获取显示器信息失败: {e}")
|
|||
|
|
return displays
|
|||
|
|
|
|||
|
|
class ConfigDialog(QDialog):
|
|||
|
|
def __init__(self, config, parent=None):
|
|||
|
|
super().__init__(parent)
|
|||
|
|
self.config = config
|
|||
|
|
self.setWindowTitle("设置")
|
|||
|
|
self.setFixedSize(450, 280)
|
|||
|
|
|
|||
|
|
layout = QFormLayout(self)
|
|||
|
|
|
|||
|
|
self.server_ip_edit = QLineEdit(config["server_ip"])
|
|||
|
|
self.mediamtx_path_edit = QLineEdit(config["mediamtx_path"])
|
|||
|
|
self.ffmpeg_path_edit = QLineEdit(config["ffmpeg_path"])
|
|||
|
|
self.front_stream_edit = QLineEdit(config["front_stream_path"])
|
|||
|
|
self.back_stream_edit = QLineEdit(config["back_stream_path"])
|
|||
|
|
|
|||
|
|
self.mediamtx_btn = QPushButton("浏览...")
|
|||
|
|
self.mediamtx_btn.clicked.connect(lambda: self.browse("mediamtx"))
|
|||
|
|
self.ffmpeg_btn = QPushButton("浏览...")
|
|||
|
|
self.ffmpeg_btn.clicked.connect(lambda: self.browse("ffmpeg"))
|
|||
|
|
|
|||
|
|
path_layout = QGridLayout()
|
|||
|
|
path_layout.addWidget(self.mediamtx_path_edit, 0, 0)
|
|||
|
|
path_layout.addWidget(self.mediamtx_btn, 0, 1)
|
|||
|
|
path_layout.addWidget(self.ffmpeg_path_edit, 1, 0)
|
|||
|
|
path_layout.addWidget(self.ffmpeg_btn, 1, 1)
|
|||
|
|
|
|||
|
|
layout.addRow("服务器IP:", self.server_ip_edit)
|
|||
|
|
layout.addRow("MediaMTX路径:", self.mediamtx_path_edit)
|
|||
|
|
layout.addRow("FFmpeg路径:", self.ffmpeg_path_edit)
|
|||
|
|
layout.addRow("前置屏幕流名:", self.front_stream_edit)
|
|||
|
|
layout.addRow("后置屏幕流名:", self.back_stream_edit)
|
|||
|
|
|
|||
|
|
buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
|
|||
|
|
buttons.accepted.connect(self.accept)
|
|||
|
|
buttons.rejected.connect(self.reject)
|
|||
|
|
layout.addRow(buttons)
|
|||
|
|
|
|||
|
|
def browse(self, field):
|
|||
|
|
if field == "mediamtx":
|
|||
|
|
path = QFileDialog.getOpenFileName(self, "选择MediaMTX", "", "Executable (*.exe)")[0]
|
|||
|
|
if path:
|
|||
|
|
self.mediamtx_path_edit.setText(path)
|
|||
|
|
elif field == "ffmpeg":
|
|||
|
|
path = QFileDialog.getOpenFileName(self, "选择FFmpeg", "", "Executable (*.exe)")[0]
|
|||
|
|
if path:
|
|||
|
|
self.ffmpeg_path_edit.setText(path)
|
|||
|
|
|
|||
|
|
def get_config(self):
|
|||
|
|
return {
|
|||
|
|
"server_ip": self.server_ip_edit.text().strip(),
|
|||
|
|
"mediamtx_path": self.mediamtx_path_edit.text().strip(),
|
|||
|
|
"ffmpeg_path": self.ffmpeg_path_edit.text().strip(),
|
|||
|
|
"front_stream_path": self.front_stream_edit.text().strip() or "front",
|
|||
|
|
"back_stream_path": self.back_stream_edit.text().strip() or "back"
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
class ConnectionChecker(QThread):
|
|||
|
|
status_update = Signal(dict)
|
|||
|
|
|
|||
|
|
def run(self):
|
|||
|
|
while True:
|
|||
|
|
cfg = self.config
|
|||
|
|
status = {
|
|||
|
|
"mediamtx": self.check_mediamtx(cfg["mediamtx_path"]),
|
|||
|
|
"ffmpeg": self.check_ffmpeg(cfg["ffmpeg_path"]),
|
|||
|
|
"server": self.check_server(cfg["server_ip"]),
|
|||
|
|
"server_port": self.check_port(cfg["server_ip"], 8554),
|
|||
|
|
}
|
|||
|
|
self.status_update.emit(status)
|
|||
|
|
time.sleep(3)
|
|||
|
|
|
|||
|
|
def set_config(self, config):
|
|||
|
|
self.config = config
|
|||
|
|
|
|||
|
|
def check_mediamtx(self, path):
|
|||
|
|
for proc in psutil.process_iter(['name']):
|
|||
|
|
if proc.info['name'] == 'mediamtx.exe':
|
|||
|
|
return True
|
|||
|
|
return False
|
|||
|
|
|
|||
|
|
def check_ffmpeg(self, path):
|
|||
|
|
return os.path.exists(path)
|
|||
|
|
|
|||
|
|
def check_server(self, ip):
|
|||
|
|
try:
|
|||
|
|
socket.create_connection((ip, 8554), timeout=2)
|
|||
|
|
return True
|
|||
|
|
except:
|
|||
|
|
return False
|
|||
|
|
|
|||
|
|
def check_port(self, ip, port):
|
|||
|
|
try:
|
|||
|
|
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|||
|
|
sock.settimeout(2)
|
|||
|
|
result = sock.connect_ex((ip, port))
|
|||
|
|
sock.close()
|
|||
|
|
return result == 0
|
|||
|
|
except:
|
|||
|
|
return False
|
|||
|
|
|
|||
|
|
class MainWindow(QMainWindow):
|
|||
|
|
def __init__(self):
|
|||
|
|
super().__init__()
|
|||
|
|
self.config = load_config()
|
|||
|
|
self.processes = []
|
|||
|
|
self.setWindowTitle("双屏投屏控制")
|
|||
|
|
self.setFixedSize(600, 500)
|
|||
|
|
|
|||
|
|
self.status_bar = QStatusBar()
|
|||
|
|
self.setStatusBar(self.status_bar)
|
|||
|
|
|
|||
|
|
central_widget = QWidget()
|
|||
|
|
self.setCentralWidget(central_widget)
|
|||
|
|
main_layout = QVBoxLayout(central_widget)
|
|||
|
|
|
|||
|
|
title_layout = QHBoxLayout()
|
|||
|
|
title_layout.addStretch()
|
|||
|
|
self.btn_settings = QPushButton("⚙ 设置")
|
|||
|
|
self.btn_settings.setFixedSize(70, 30)
|
|||
|
|
self.btn_settings.clicked.connect(self.open_settings)
|
|||
|
|
title_layout.addWidget(self.btn_settings)
|
|||
|
|
|
|||
|
|
title_label = QLabel("双屏投屏系统")
|
|||
|
|
title_font = QFont("Microsoft YaHei", 14, QFont.Bold)
|
|||
|
|
title_label.setFont(title_font)
|
|||
|
|
title_label.setAlignment(Qt.AlignCenter)
|
|||
|
|
|
|||
|
|
title_main_layout = QVBoxLayout()
|
|||
|
|
title_main_layout.addLayout(title_layout)
|
|||
|
|
title_main_layout.addWidget(title_label)
|
|||
|
|
main_layout.addLayout(title_main_layout)
|
|||
|
|
|
|||
|
|
self.displays = get_displays()
|
|||
|
|
display_info = QLabel(f"检测到 {len(self.displays)} 个显示器")
|
|||
|
|
display_info.setAlignment(Qt.AlignCenter)
|
|||
|
|
main_layout.addWidget(display_info)
|
|||
|
|
|
|||
|
|
self.display_group = QGroupBox("显示器信息")
|
|||
|
|
display_layout = QGridLayout()
|
|||
|
|
|
|||
|
|
self.display_labels = []
|
|||
|
|
for i, display in enumerate(self.displays):
|
|||
|
|
label = QLabel(f"显示器{i+1}: {display['width']}x{display['height']} "
|
|||
|
|
f"(@ {display['x']},{display['y']}) "
|
|||
|
|
f"{'主屏' if display['primary'] else ''}")
|
|||
|
|
label.setStyleSheet("padding: 5px;")
|
|||
|
|
display_layout.addWidget(label, i, 0)
|
|||
|
|
self.display_labels.append(label)
|
|||
|
|
|
|||
|
|
self.display_group.setLayout(display_layout)
|
|||
|
|
main_layout.addWidget(self.display_group)
|
|||
|
|
|
|||
|
|
config_group = QGroupBox("本地配置")
|
|||
|
|
config_layout = QGridLayout()
|
|||
|
|
|
|||
|
|
self.mediamtx_label = QLabel("MediaMTX: 未检测")
|
|||
|
|
self.ffmpeg_label = QLabel("FFmpeg: 未检测")
|
|||
|
|
self.server_label = QLabel("服务器连接: 未检测")
|
|||
|
|
|
|||
|
|
self.mediamtx_indicator = QLabel("●")
|
|||
|
|
self.mediamtx_indicator.setFixedWidth(20)
|
|||
|
|
self.ffmpeg_indicator = QLabel("●")
|
|||
|
|
self.ffmpeg_indicator.setFixedWidth(20)
|
|||
|
|
self.server_indicator = QLabel("●")
|
|||
|
|
self.server_indicator.setFixedWidth(20)
|
|||
|
|
|
|||
|
|
config_layout.addWidget(self.mediamtx_indicator, 0, 0)
|
|||
|
|
config_layout.addWidget(self.mediamtx_label, 0, 1)
|
|||
|
|
config_layout.addWidget(self.ffmpeg_indicator, 1, 0)
|
|||
|
|
config_layout.addWidget(self.ffmpeg_label, 1, 1)
|
|||
|
|
config_layout.addWidget(self.server_indicator, 2, 0)
|
|||
|
|
config_layout.addWidget(self.server_label, 2, 1)
|
|||
|
|
|
|||
|
|
config_group.setLayout(config_layout)
|
|||
|
|
main_layout.addWidget(config_group)
|
|||
|
|
|
|||
|
|
self.info_group = QGroupBox("流信息")
|
|||
|
|
self.update_info_group()
|
|||
|
|
main_layout.addWidget(self.info_group)
|
|||
|
|
|
|||
|
|
btn_layout = QHBoxLayout()
|
|||
|
|
|
|||
|
|
self.btn_front = QPushButton("🎬 投送前置屏幕")
|
|||
|
|
self.btn_front.setFixedHeight(45)
|
|||
|
|
self.btn_front.setFont(QFont("Microsoft YaHei", 10))
|
|||
|
|
self.btn_front.setStyleSheet("""
|
|||
|
|
QPushButton {
|
|||
|
|
background-color: #1976D2;
|
|||
|
|
color: white;
|
|||
|
|
border: none;
|
|||
|
|
border-radius: 8px;
|
|||
|
|
font-weight: bold;
|
|||
|
|
}
|
|||
|
|
QPushButton:hover {
|
|||
|
|
background-color: #1565C0;
|
|||
|
|
}
|
|||
|
|
""")
|
|||
|
|
self.btn_front.clicked.connect(lambda: self.push_screen("front"))
|
|||
|
|
|
|||
|
|
self.btn_back = QPushButton("🎬 投送后置屏幕")
|
|||
|
|
self.btn_back.setFixedHeight(45)
|
|||
|
|
self.btn_back.setFont(QFont("Microsoft YaHei", 10))
|
|||
|
|
self.btn_back.setStyleSheet("""
|
|||
|
|
QPushButton {
|
|||
|
|
background-color: #388E3C;
|
|||
|
|
color: white;
|
|||
|
|
border: none;
|
|||
|
|
border-radius: 8px;
|
|||
|
|
font-weight: bold;
|
|||
|
|
}
|
|||
|
|
QPushButton:hover {
|
|||
|
|
background-color: #2E7D32;
|
|||
|
|
}
|
|||
|
|
""")
|
|||
|
|
self.btn_back.clicked.connect(lambda: self.push_screen("back"))
|
|||
|
|
|
|||
|
|
btn_layout.addWidget(self.btn_front)
|
|||
|
|
btn_layout.addWidget(self.btn_back)
|
|||
|
|
main_layout.addLayout(btn_layout)
|
|||
|
|
|
|||
|
|
self.btn_stop = QPushButton("⏹ 停止所有推流")
|
|||
|
|
self.btn_stop.setFixedHeight(40)
|
|||
|
|
self.btn_stop.setFont(QFont("Microsoft YaHei", 10))
|
|||
|
|
self.btn_stop.setStyleSheet("""
|
|||
|
|
QPushButton {
|
|||
|
|
background-color: #D32F2F;
|
|||
|
|
color: white;
|
|||
|
|
border: none;
|
|||
|
|
border-radius: 8px;
|
|||
|
|
font-weight: bold;
|
|||
|
|
}
|
|||
|
|
QPushButton:hover {
|
|||
|
|
background-color: #C62828;
|
|||
|
|
}
|
|||
|
|
""")
|
|||
|
|
self.btn_stop.clicked.connect(self.stop_all)
|
|||
|
|
main_layout.addWidget(self.btn_stop)
|
|||
|
|
|
|||
|
|
self.log_label = QLabel("日志: 等待操作...")
|
|||
|
|
self.log_label.setWordWrap(True)
|
|||
|
|
self.log_label.setFrameShape(QFrame.Shape.StyledPanel)
|
|||
|
|
self.log_label.setFixedHeight(50)
|
|||
|
|
main_layout.addWidget(self.log_label)
|
|||
|
|
|
|||
|
|
self.checker = ConnectionChecker()
|
|||
|
|
self.checker.set_config(self.config)
|
|||
|
|
self.checker.status_update.connect(self.update_status)
|
|||
|
|
self.checker.start()
|
|||
|
|
|
|||
|
|
logger.info("双屏投屏控制界面已初始化")
|
|||
|
|
|
|||
|
|
def update_info_group(self):
|
|||
|
|
cfg = self.config
|
|||
|
|
if self.info_group.layout():
|
|||
|
|
QWidget().setLayout(self.info_group.layout())
|
|||
|
|
info_layout = QGridLayout()
|
|||
|
|
info_layout.addWidget(QLabel("服务器IP:"), 0, 0)
|
|||
|
|
info_layout.addWidget(QLabel(cfg["server_ip"]), 0, 1)
|
|||
|
|
info_layout.addWidget(QLabel("前置屏幕流:"), 1, 0)
|
|||
|
|
info_layout.addWidget(QLabel(f"rtsp://{cfg['server_ip']}:8554/{cfg['front_stream_path']}"), 1, 1)
|
|||
|
|
info_layout.addWidget(QLabel("后置屏幕流:"), 2, 0)
|
|||
|
|
info_layout.addWidget(QLabel(f"rtsp://{cfg['server_ip']}:8554/{cfg['back_stream_path']}"), 2, 1)
|
|||
|
|
self.info_group.setLayout(info_layout)
|
|||
|
|
|
|||
|
|
def open_settings(self):
|
|||
|
|
dialog = ConfigDialog(self.config, self)
|
|||
|
|
if dialog.exec():
|
|||
|
|
self.config = dialog.get_config()
|
|||
|
|
save_config(self.config)
|
|||
|
|
self.checker.set_config(self.config)
|
|||
|
|
self.update_info_group()
|
|||
|
|
self.log_label.setText("日志: 配置已更新")
|
|||
|
|
|
|||
|
|
def update_status(self, status):
|
|||
|
|
if status["mediamtx"]:
|
|||
|
|
self.mediamtx_indicator.setStyleSheet("color: green; font-size: 16px;")
|
|||
|
|
self.mediamtx_label.setText("MediaMTX: ✓ 运行中")
|
|||
|
|
else:
|
|||
|
|
self.mediamtx_indicator.setStyleSheet("color: red; font-size: 16px;")
|
|||
|
|
self.mediamtx_label.setText("MediaMTX: ✗ 未运行")
|
|||
|
|
|
|||
|
|
if status["ffmpeg"]:
|
|||
|
|
self.ffmpeg_indicator.setStyleSheet("color: green; font-size: 16px;")
|
|||
|
|
self.ffmpeg_label.setText("FFmpeg: ✓ 已安装")
|
|||
|
|
else:
|
|||
|
|
self.ffmpeg_indicator.setStyleSheet("color: red; font-size: 16px;")
|
|||
|
|
self.ffmpeg_label.setText("FFmpeg: ✗ 未找到")
|
|||
|
|
|
|||
|
|
if status["server"]:
|
|||
|
|
self.server_indicator.setStyleSheet("color: green; font-size: 16px;")
|
|||
|
|
self.server_label.setText("服务器连接: ✓ 可连接")
|
|||
|
|
else:
|
|||
|
|
self.server_indicator.setStyleSheet("color: orange; font-size: 16px;")
|
|||
|
|
self.server_label.setText("服务器连接: ✗ 连接失败")
|
|||
|
|
|
|||
|
|
def push_screen(self, screen_type):
|
|||
|
|
cfg = self.config
|
|||
|
|
displays = self.displays
|
|||
|
|
|
|||
|
|
if len(displays) < 2:
|
|||
|
|
QMessageBox.warning(self, "错误", "未检测到足够的显示器!")
|
|||
|
|
return
|
|||
|
|
|
|||
|
|
if screen_type == "front":
|
|||
|
|
display = displays[0]
|
|||
|
|
stream_path = cfg["front_stream_path"]
|
|||
|
|
screen_name = "前置屏幕"
|
|||
|
|
else:
|
|||
|
|
display = displays[1] if len(displays) > 1 else displays[0]
|
|||
|
|
stream_path = cfg["back_stream_path"]
|
|||
|
|
screen_name = "后置屏幕"
|
|||
|
|
|
|||
|
|
logger.info(f"开始投送{screen_name}: {display['width']}x{display['height']}")
|
|||
|
|
|
|||
|
|
cmd = [
|
|||
|
|
cfg["ffmpeg_path"],
|
|||
|
|
"-f", "gdigrab",
|
|||
|
|
"-offset_x", str(display['x']),
|
|||
|
|
"-offset_y", str(display['y']),
|
|||
|
|
"-video_size", f"{display['width']}x{display['height']}",
|
|||
|
|
"-framerate", "30",
|
|||
|
|
"-i", "desktop",
|
|||
|
|
"-c:v", "libx264",
|
|||
|
|
"-preset", "ultrafast",
|
|||
|
|
"-tune", "zerolatency",
|
|||
|
|
"-f", "rtsp",
|
|||
|
|
f"rtsp://{cfg['server_ip']}:8554/{stream_path}"
|
|||
|
|
]
|
|||
|
|
|
|||
|
|
logger.info(f"执行FFmpeg命令: {' '.join(cmd)}")
|
|||
|
|
process = subprocess.Popen(cmd, creationflags=subprocess.CREATE_NEW_CONSOLE)
|
|||
|
|
self.processes.append(process)
|
|||
|
|
|
|||
|
|
self.log_label.setText(f"日志: {screen_name}正在投送... "
|
|||
|
|
f"访问 http://{cfg['server_ip']}:8889/{stream_path}")
|
|||
|
|
QMessageBox.information(self, "提示",
|
|||
|
|
f"{screen_name}投送已启动!\n\n"
|
|||
|
|
f"观众访问:http://{cfg['server_ip']}:8889/{stream_path}")
|
|||
|
|
|
|||
|
|
def stop_all(self):
|
|||
|
|
for proc in self.processes:
|
|||
|
|
try:
|
|||
|
|
proc.terminate()
|
|||
|
|
except:
|
|||
|
|
pass
|
|||
|
|
self.processes.clear()
|
|||
|
|
|
|||
|
|
for proc in psutil.process_iter(['name']):
|
|||
|
|
try:
|
|||
|
|
if proc.info['name'] == 'ffmpeg.exe':
|
|||
|
|
proc.terminate()
|
|||
|
|
except:
|
|||
|
|
pass
|
|||
|
|
|
|||
|
|
self.log_label.setText("日志: 所有推流已停止")
|
|||
|
|
logger.info("所有推流已停止")
|
|||
|
|
|
|||
|
|
def closeEvent(self, event):
|
|||
|
|
self.stop_all()
|
|||
|
|
self.checker.terminate()
|
|||
|
|
event.accept()
|
|||
|
|
|
|||
|
|
if __name__ == "__main__":
|
|||
|
|
logger.info("应用程序启动")
|
|||
|
|
app = QApplication(sys.argv)
|
|||
|
|
app.setStyle("Fusion")
|
|||
|
|
window = MainWindow()
|
|||
|
|
window.show()
|
|||
|
|
sys.exit(app.exec())
|