|
| 1 | +try: |
| 2 | + from . import ui_audioPlayerWidget |
| 3 | +except (ImportError, ModuleNotFoundError): |
| 4 | + try: |
| 5 | + from audio_player import ui_audioPlayerWidget |
| 6 | + except (ImportError, ModuleNotFoundError): |
| 7 | + import ui_audioPlayerWidget |
| 8 | +from PySide6 import QtWidgets, QtCore |
| 9 | +import subprocess, json, requests |
| 10 | +from ffpyplayer.player import MediaPlayer |
| 11 | +from io import BytesIO |
| 12 | +import threading |
| 13 | +import os, re, signal, time, tempfile |
| 14 | + |
| 15 | + |
| 16 | +class AudioPlayerWidget(ui_audioPlayerWidget.Ui_Form, QtWidgets.QWidget): |
| 17 | + def __init__(self, parent=None): |
| 18 | + super().__init__(parent=parent) |
| 19 | + self.setupUi(self) |
| 20 | + self.media_player = None |
| 21 | + self.is_playing = False |
| 22 | + self.is_dragging = False |
| 23 | + self.is_end = False |
| 24 | + self.length = 0.0 # 音频时长(秒) |
| 25 | + self.audio_link = "" |
| 26 | + self.temp_file_path = "" # 临时文件路径(核心修改) |
| 27 | + self.supports_range = False |
| 28 | + self.download_thread = None |
| 29 | + self.is_downloading = False |
| 30 | + |
| 31 | + self.pushButton_play.clicked.connect(self._play) |
| 32 | + self.pushButton_stop.clicked.connect(self.stop) |
| 33 | + |
| 34 | + self.interval_timer = QtCore.QTimer() |
| 35 | + self.interval_timer.timeout.connect(self._play_interval) |
| 36 | + self.interval_timer.start(100) |
| 37 | + |
| 38 | + self.init_progress_bar() |
| 39 | + self.label_download = QtWidgets.QLabel("") |
| 40 | + self.layout().addWidget(self.label_download) |
| 41 | + |
| 42 | + def _play(self): |
| 43 | + if self.is_playing: |
| 44 | + self.pause() |
| 45 | + else: |
| 46 | + self.play() |
| 47 | + |
| 48 | + def _check_range_support(self, url: str) -> bool: |
| 49 | + """检测URL是否支持部分请求(Range requests)""" |
| 50 | + try: |
| 51 | + headers = { |
| 52 | + "Range": "bytes=0-1", |
| 53 | + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36", |
| 54 | + } |
| 55 | + response = requests.head( |
| 56 | + url, headers=headers, allow_redirects=True, timeout=10 |
| 57 | + ) |
| 58 | + lower_headers = [k.lower() for k in response.headers.keys()] |
| 59 | + return response.status_code == 206 or "accept-ranges" in lower_headers |
| 60 | + except Exception as e: |
| 61 | + print(f"检测部分请求支持失败: {e}") |
| 62 | + return False |
| 63 | + |
| 64 | + def _download_audio_to_tempfile(self, url: str) -> str: |
| 65 | + """下载音频到临时文件(核心修改)""" |
| 66 | + try: |
| 67 | + self.is_downloading = True |
| 68 | + headers = { |
| 69 | + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36", |
| 70 | + "Accept": "audio/ogg, audio/mpeg, audio/*", |
| 71 | + } |
| 72 | + |
| 73 | + response = requests.get(url, headers=headers, stream=True, timeout=10) |
| 74 | + response.raise_for_status() |
| 75 | + |
| 76 | + total_size = int(response.headers.get("content-length", 0)) |
| 77 | + downloaded_size = 0 |
| 78 | + |
| 79 | + # 创建临时文件(自动添加音频扩展名) |
| 80 | + suffix = ".ogg" if url.lower().endswith(".ogg") else ".mp3" |
| 81 | + with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as tmp: |
| 82 | + temp_path = tmp.name |
| 83 | + for chunk in response.iter_content(chunk_size=8192): |
| 84 | + if chunk: |
| 85 | + tmp.write(chunk) |
| 86 | + downloaded_size += len(chunk) |
| 87 | + if total_size > 0: |
| 88 | + progress = (downloaded_size / total_size) * 100 |
| 89 | + self.update_download_label(f"下载中: {progress:.1f}%") |
| 90 | + |
| 91 | + self.update_download_label("下载完成,准备播放") |
| 92 | + return temp_path # 返回临时文件路径 |
| 93 | + except Exception as e: |
| 94 | + self.update_download_label(f"下载失败: {str(e)}") |
| 95 | + print(f"下载音频失败: {e}") |
| 96 | + return None |
| 97 | + finally: |
| 98 | + self.is_downloading = False |
| 99 | + |
| 100 | + def update_download_label(self, text: str): |
| 101 | + """线程安全地更新下载标签""" |
| 102 | + QtCore.QMetaObject.invokeMethod( |
| 103 | + self.label_download, |
| 104 | + "setText", |
| 105 | + QtCore.Qt.QueuedConnection, |
| 106 | + QtCore.Q_ARG(str, text), |
| 107 | + ) |
| 108 | + |
| 109 | + def _getlength(self, file_path: str) -> float: |
| 110 | + """从临时文件获取时长(稳定可靠)""" |
| 111 | + if not file_path or not os.path.exists(file_path): |
| 112 | + return 0.0 |
| 113 | + |
| 114 | + # 优先用ffprobe(最可靠) |
| 115 | + try: |
| 116 | + cmd = [ |
| 117 | + "ffprobe", |
| 118 | + "-v", |
| 119 | + "quiet", |
| 120 | + "-print_format", |
| 121 | + "json", |
| 122 | + "-show_format", |
| 123 | + file_path, |
| 124 | + ] |
| 125 | + result = subprocess.run( |
| 126 | + cmd, |
| 127 | + stdout=subprocess.PIPE, |
| 128 | + stderr=subprocess.STDOUT, |
| 129 | + text=True, |
| 130 | + encoding="utf-8", |
| 131 | + timeout=10, |
| 132 | + ) |
| 133 | + info = json.loads(result.stdout) |
| 134 | + return float(info["format"]["duration"]) |
| 135 | + except Exception as e: |
| 136 | + print(f"ffprobe获取时长失败: {e}") |
| 137 | + |
| 138 | + # 备选:用ffmpeg |
| 139 | + try: |
| 140 | + cmd = [ |
| 141 | + "ffmpeg", |
| 142 | + "-i", |
| 143 | + file_path, |
| 144 | + "-v", |
| 145 | + "quiet", |
| 146 | + "-show_entries", |
| 147 | + "format=duration", |
| 148 | + "-print_format", |
| 149 | + "json", |
| 150 | + "pipe:1", |
| 151 | + ] |
| 152 | + result = subprocess.run( |
| 153 | + cmd, |
| 154 | + stdout=subprocess.PIPE, |
| 155 | + stderr=subprocess.STDOUT, |
| 156 | + text=True, |
| 157 | + encoding="utf-8", |
| 158 | + timeout=10, |
| 159 | + ) |
| 160 | + info = json.loads(result.stdout) |
| 161 | + return float(info["format"]["duration"]) |
| 162 | + except Exception as e: |
| 163 | + print(f"ffmpeg获取时长失败: {e}") |
| 164 | + |
| 165 | + # 最后:文件大小估算 |
| 166 | + try: |
| 167 | + file_size = os.path.getsize(file_path) |
| 168 | + avg_bitrate = 128000 # 128kbps |
| 169 | + return (file_size * 8) / avg_bitrate |
| 170 | + except Exception as e: |
| 171 | + print(f"估算时长失败: {e}") |
| 172 | + return 0.0 |
| 173 | + |
| 174 | + def load_audio(self, audio_link: str): |
| 175 | + """加载音频(优先临时文件)""" |
| 176 | + self.stop() # 停止当前播放 |
| 177 | + self.audio_link = audio_link |
| 178 | + self.temp_file_path = "" # 清空旧临时文件 |
| 179 | + self.label_status.setText("加载中...") |
| 180 | + self.label_download.setText("") |
| 181 | + |
| 182 | + # 网络链接处理 |
| 183 | + if audio_link.startswith(("http://", "https://")): |
| 184 | + self.supports_range = self._check_range_support(audio_link) |
| 185 | + |
| 186 | + if self.supports_range: |
| 187 | + # 支持部分请求:直接播放URL |
| 188 | + self.media_player = MediaPlayer(audio_link) |
| 189 | + self.media_player.set_pause(True) |
| 190 | + self.length = self._getlength(audio_link) # 此时file_path是URL |
| 191 | + self.label_status.setText("Ready") |
| 192 | + else: |
| 193 | + # 不支持部分请求:下载到临时文件 |
| 194 | + self.label_status.setText("不支持部分请求,准备下载...") |
| 195 | + self.download_thread = threading.Thread( |
| 196 | + target=self._download_and_initialize, args=(audio_link,) |
| 197 | + ) |
| 198 | + self.download_thread.daemon = True |
| 199 | + self.download_thread.start() |
| 200 | + else: |
| 201 | + # 本地文件:直接使用 |
| 202 | + self.temp_file_path = audio_link # 本地文件路径作为"临时文件" |
| 203 | + self.media_player = MediaPlayer(audio_link) |
| 204 | + self.media_player.set_pause(True) |
| 205 | + self.length = self._getlength(audio_link) |
| 206 | + self.label_status.setText("Ready") |
| 207 | + |
| 208 | + def _download_and_initialize(self, audio_link: str): |
| 209 | + """下载到临时文件后初始化播放器""" |
| 210 | + temp_path = self._download_audio_to_tempfile(audio_link) |
| 211 | + if not temp_path: |
| 212 | + QtCore.QMetaObject.invokeMethod( |
| 213 | + self, |
| 214 | + lambda: self.label_status.setText("下载失败,无法播放"), |
| 215 | + QtCore.Qt.QueuedConnection, |
| 216 | + ) |
| 217 | + return |
| 218 | + |
| 219 | + # 保存临时文件路径并初始化播放器 |
| 220 | + self.temp_file_path = temp_path |
| 221 | + QtCore.QMetaObject.invokeMethod( |
| 222 | + self, "_post_download_init", QtCore.Qt.QueuedConnection |
| 223 | + ) |
| 224 | + |
| 225 | + @QtCore.Slot() |
| 226 | + def _post_download_init(self): |
| 227 | + """从临时文件初始化播放器""" |
| 228 | + if not self.temp_file_path or not os.path.exists(self.temp_file_path): |
| 229 | + self.label_status.setText("临时文件不存在") |
| 230 | + return |
| 231 | + |
| 232 | + # 直接从临时文件播放 |
| 233 | + self.media_player = MediaPlayer(self.temp_file_path) |
| 234 | + self.media_player.set_pause(True) |
| 235 | + self.length = self._getlength(self.temp_file_path) |
| 236 | + self.label_status.setText(f"Ready (时长: {self.length:.2f}秒)") |
| 237 | + |
| 238 | + def play(self): |
| 239 | + if self.is_downloading or not self.media_player: |
| 240 | + return |
| 241 | + |
| 242 | + self.is_end = False |
| 243 | + self.is_playing = True |
| 244 | + self.media_player.set_pause(False) |
| 245 | + self.label_status.setText("Playing...") |
| 246 | + self.pushButton_play.setText(";") |
| 247 | + |
| 248 | + def pause(self): |
| 249 | + self.is_playing = False |
| 250 | + if self.media_player: |
| 251 | + self.media_player.set_pause(True) |
| 252 | + self.label_status.setText("Paused") |
| 253 | + self.pushButton_play.setText("4") |
| 254 | + |
| 255 | + def stop(self, *args, is_end=False): |
| 256 | + self.is_playing = False |
| 257 | + if self.media_player: |
| 258 | + self.media_player.set_pause(True) |
| 259 | + if self.media_player.get_pts() > 0: |
| 260 | + self.media_player.seek(-self.media_player.get_pts()) |
| 261 | + if not is_end: |
| 262 | + self.label.setText("00:00:00") |
| 263 | + self.horizontalSlider.setValue(0) |
| 264 | + self.label_status.setText("Ready") |
| 265 | + self.pushButton_play.setText("4") |
| 266 | + |
| 267 | + def _play_interval(self): |
| 268 | + if ( |
| 269 | + self.media_player |
| 270 | + and self.is_playing |
| 271 | + and not self.is_dragging |
| 272 | + and not self.is_downloading |
| 273 | + ): |
| 274 | + try: |
| 275 | + current_pos = self.media_player.get_pts() |
| 276 | + |
| 277 | + # 动态更新时长(如果初始获取失败) |
| 278 | + if self.length <= 0: |
| 279 | + self.length = self._getlength( |
| 280 | + self.temp_file_path if self.temp_file_path else self.audio_link |
| 281 | + ) |
| 282 | + |
| 283 | + # 检测播放结束 |
| 284 | + if self.length > 0 and current_pos >= self.length - 0.5: |
| 285 | + self.is_end = True |
| 286 | + self.stop(is_end=True) |
| 287 | + return |
| 288 | + |
| 289 | + # 更新时间显示 |
| 290 | + hours = int(current_pos // 3600) |
| 291 | + mins = int((current_pos % 3600) // 60) |
| 292 | + secs = int(current_pos % 60) |
| 293 | + self.label.setText(f"{hours:02d}:{mins:02d}:{secs:02d}") |
| 294 | + |
| 295 | + # 更新进度条 |
| 296 | + if self.length > 0: |
| 297 | + self.horizontalSlider.setValue( |
| 298 | + int((current_pos / self.length) * 100) |
| 299 | + ) |
| 300 | + except Exception as e: |
| 301 | + print(f"播放更新出错: {e}") |
| 302 | + |
| 303 | + def init_progress_bar(self): |
| 304 | + self.horizontalSlider.setRange(0, 100) |
| 305 | + self.horizontalSlider.sliderPressed.connect(self.on_slider_pressed) |
| 306 | + self.horizontalSlider.sliderReleased.connect(self.on_slider_released) |
| 307 | + self.horizontalSlider.valueChanged.connect(self.on_slider_changed) |
| 308 | + |
| 309 | + def on_slider_pressed(self): |
| 310 | + self.is_dragging = True |
| 311 | + |
| 312 | + def on_slider_released(self): |
| 313 | + if not self.media_player or self.length <= 0: |
| 314 | + self.is_dragging = False |
| 315 | + return |
| 316 | + try: |
| 317 | + target_pos = (self.horizontalSlider.value() / 100.0) * self.length |
| 318 | + if target_pos >= self.length * 0.99: |
| 319 | + self.stop() |
| 320 | + else: |
| 321 | + current_pos = self.media_player.get_pts() |
| 322 | + self.media_player.seek(target_pos - current_pos) |
| 323 | + except Exception as e: |
| 324 | + print(f"进度条调整出错: {e}") |
| 325 | + self.is_dragging = False |
| 326 | + |
| 327 | + def on_slider_changed(self): |
| 328 | + if self.is_dragging and self.length > 0: |
| 329 | + target_pos = (self.horizontalSlider.value() / 100.0) * self.length |
| 330 | + hours = int(target_pos // 3600) |
| 331 | + mins = int((target_pos % 3600) // 60) |
| 332 | + secs = int(target_pos % 60) |
| 333 | + self.label.setText(f"{hours:02d}:{mins:02d}:{secs:02d}") |
| 334 | + |
| 335 | + def closeEvent(self, event): |
| 336 | + """关闭时删除临时文件(核心修改)""" |
| 337 | + self.stop() |
| 338 | + # 确保临时文件被删除 |
| 339 | + if self.temp_file_path and os.path.exists(self.temp_file_path): |
| 340 | + try: |
| 341 | + os.unlink(self.temp_file_path) |
| 342 | + print(f"已删除临时文件: {self.temp_file_path}") |
| 343 | + except Exception as e: |
| 344 | + print(f"删除临时文件失败: {e}") |
| 345 | + event.accept() |
| 346 | + |
| 347 | + |
| 348 | +if __name__ == "__main__": |
| 349 | + import sys |
| 350 | + |
| 351 | + qa = QtWidgets.QApplication(sys.argv) |
| 352 | + apw = AudioPlayerWidget() |
| 353 | + apw.show() |
| 354 | + test_url = "http://localhost/Audio_2.ogg" # 替换为实际URL |
| 355 | + apw.load_audio(test_url) |
| 356 | + qa.exec() |
0 commit comments