import sys import requests import pygame import time import os from PyQt5.QtWidgets import ( QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, QTreeWidget, QTreeWidgetItem, QLineEdit, QLabel, QComboBox, QPushButton, QSplitter, QTabWidget, QTextEdit, QMessageBox, QFrame, QAction, QMenu, QSlider ) from PyQt5.QtCore import Qt, pyqtSignal, QThread, QTimer, QObject from PyQt5.QtGui import QColor, QBrush # --- 初始化 pygame 音频模块 --- pygame.mixer.init() # --- 配置区 --- BASE_URL = "https://shanwogou.cn/audio/" API_URL = BASE_URL + "api.php" # --- API 交互模块 --- def fetch_api_data(url, params=None): """通用 GET 请求函数,解析 JSON 响应""" try: response = requests.get(url, params=params, timeout=15) response.raise_for_status() # 触发 HTTP 错误(如 404/500) return response.json() except requests.exceptions.RequestException as e: print(f"API 请求失败: {str(e)}") return None def get_all_music(): """获取所有音乐""" return fetch_api_data(API_URL, params={"action": "getAllMusic"}) def get_music_tags(): """获取所有音乐标签""" return fetch_api_data(API_URL, params={"action": "getMusicTags"}) def get_announcements(): """获取公告列表""" return fetch_api_data(API_URL, params={"action": "getAnnouncements"}) def get_music_url(music_id): """获取音乐的直接播放地址(mp3 链接)""" return fetch_api_data(API_URL, params={"action": "getMusicUrl", "id": music_id}) # --- 后台音频下载线程 --- class AudioDownloadThread(QThread): """后台下载音频文件,下载完成后发送信号""" download_finished = pyqtSignal(str, str) # 信号:音乐标题、本地临时文件路径 download_failed = pyqtSignal(str) # 信号:失败原因 download_progress = pyqtSignal(int) # 信号:下载进度(0-100) def __init__(self, music_id, music_title): super().__init__() self.music_id = music_id self.music_title = music_title self.is_canceled = False # 取消下载标记 def run(self): try: # 1. 获取音乐的直接播放链接 music_url_data = get_music_url(self.music_id) if not music_url_data or not music_url_data.get("success", False): self.download_failed.emit("未获取到有效的播放链接") return play_url = music_url_data["data"].get("play_url") if not play_url: self.download_failed.emit("API 未返回直接播放地址(play_url)") return # 2. 下载音频文件到临时目录 temp_file = f"temp_{self.music_id}_{int(time.time())}.mp3" response = requests.get(play_url, stream=True, timeout=20) response.raise_for_status() # 获取文件总大小(用于计算进度) total_size = int(response.headers.get("content-length", 0)) downloaded_size = 0 with open(temp_file, "wb") as f: for chunk in response.iter_content(chunk_size=8192): if self.is_canceled: # 检查是否取消下载 if os.path.exists(temp_file): os.remove(temp_file) self.download_failed.emit("下载已取消") return if chunk: f.write(chunk) downloaded_size += len(chunk) # 计算并发送进度(避免除零错误) if total_size > 0: progress = int((downloaded_size / total_size) * 100) self.download_progress.emit(progress) # 3. 下载完成,发送信号 self.download_finished.emit(self.music_title, temp_file) except Exception as e: self.download_failed.emit(f"下载失败:{str(e)}") def cancel_download(self): """取消当前下载任务""" self.is_canceled = True # --- 数据加载工作类(继承自QObject,支持moveToThread)--- class DataLoadWorker(QObject): """数据加载工作类(与主线程分离,避免UI卡顿)""" music_loaded = pyqtSignal(dict) tags_loaded = pyqtSignal(dict) announcements_loaded = pyqtSignal(dict) finished = pyqtSignal() def run(self): """加载音乐、标签、公告数据""" # 1. 加载音乐 music_data = get_all_music() self.music_loaded.emit(music_data) # 2. 加载标签 tags_data = get_music_tags() self.tags_loaded.emit(tags_data) # 3. 加载公告 announcements_data = get_announcements() self.announcements_loaded.emit(announcements_data) # 4. 发送完成信号 self.finished.emit() # --- 主窗口类 --- class SunsetMusicApp(QMainWindow): def __init__(self): super().__init__() self.setWindowTitle("Sunset Music for PC") self.setGeometry(100, 100, 1200, 700) self.setMinimumSize(1000, 600) # 最小窗口尺寸 # 数据存储 self.all_music = [] # 所有音乐列表 self.filtered_music = [] # 筛选后音乐列表 self.current_playing_file = ""# 当前播放的本地音频文件路径 self.current_playing_music = None # 当前播放的音乐信息(字典) self.download_thread = None # 音频下载线程实例 self.is_playing = False # 播放状态标记 # 过滤条件初始化 self.current_keyword = "" self.current_tag = "所有标签" # 初始化 UI self.init_ui() # 加载音乐、标签、公告数据 self.load_data_in_background() # 初始化播放进度定时器 self.init_playback_timer() def init_ui(self): """构建完整 UI 界面""" central_widget = QWidget() self.setCentralWidget(central_widget) main_layout = QVBoxLayout(central_widget) # 1. 菜单栏 self.create_menu_bar() # 2. 顶部控制区(搜索+筛选+刷新) top_control_layout = QHBoxLayout() top_control_layout.setContentsMargins(10, 10, 10, 5) # 搜索框 self.search_input = QLineEdit() self.search_input.setPlaceholderText("搜索:标题/艺术家/标签...") self.search_input.setFixedHeight(30) self.search_input.textChanged.connect(self.filter_music_by_search) top_control_layout.addWidget(self.search_input, stretch=3) # 标签筛选下拉框 self.tag_combo = QComboBox() self.tag_combo.addItem("所有标签") self.tag_combo.setFixedHeight(30) self.tag_combo.setFixedWidth(150) self.tag_combo.currentIndexChanged.connect(self.filter_music_by_tag) top_control_layout.addWidget(self.tag_combo, stretch=1) # 刷新按钮 self.refresh_btn = QPushButton("刷新列表") self.refresh_btn.setFixedHeight(30) self.refresh_btn.setFixedWidth(100) self.refresh_btn.clicked.connect(self.load_data_in_background) top_control_layout.addWidget(self.refresh_btn, stretch=0) main_layout.addLayout(top_control_layout) # 3. 中部主内容区(分割窗口:音乐列表 + 右侧内容) splitter = QSplitter(Qt.Horizontal) splitter.setContentsMargins(10, 5, 10, 10) # 3.1 左侧:音乐列表 self.music_tree = QTreeWidget() self.music_tree.setColumnCount(5) self.music_tree.setHeaderLabels(["ID", "标题", "艺术家", "标签", "时长"]) # 设置列宽 self.music_tree.setColumnWidth(0, 60) # ID 列 self.music_tree.setColumnWidth(1, 350) # 标题列 self.music_tree.setColumnWidth(2, 200) # 艺术家列 self.music_tree.setColumnWidth(3, 120) # 标签列 self.music_tree.setColumnWidth(4, 80) # 时长列 # 单选模式 + 双击播放 self.music_tree.setSelectionMode(QTreeWidget.SingleSelection) self.music_tree.itemDoubleClicked.connect(self.on_music_double_click) splitter.addWidget(self.music_tree) # 3.2 右侧:标签页(播放器 + 公告) self.right_tab = QTabWidget() self.right_tab.setMinimumWidth(500) # 右侧1:播放器面板 self.player_panel = QWidget() self.init_player_panel() # 初始化播放控制UI self.right_tab.addTab(self.player_panel, "音乐播放器") # 右侧2:公告面板 self.announcement_panel = QTextEdit() self.announcement_panel.setReadOnly(True) self.announcement_panel.setStyleSheet("font-size: 14px; padding: 10px;") self.right_tab.addTab(self.announcement_panel, "最新公告") splitter.addWidget(self.right_tab) splitter.setSizes([600, 600]) # 左右窗口比例 main_layout.addWidget(splitter, stretch=1) # 4. 底部状态栏 self.status_bar = self.statusBar() self.status_bar.showMessage("就绪:未播放音乐") def create_menu_bar(self): """创建菜单栏(文件+帮助)""" menubar = self.menuBar() # 文件菜单 file_menu = menubar.addMenu("文件") exit_action = QAction("退出", self) exit_action.setShortcut("Ctrl+Q") exit_action.triggered.connect(self.close) file_menu.addAction(exit_action) # 帮助菜单 help_menu = menubar.addMenu("帮助") about_action = QAction("关于", self) about_action.triggered.connect(self.show_about_dialog) help_menu.addAction(about_action) def init_player_panel(self): """初始化播放器面板""" panel_layout = QVBoxLayout(self.player_panel) panel_layout.setContentsMargins(20, 20, 20, 20) # 1. 当前播放音乐信息 self.music_info_label = QLabel("当前未播放音乐") self.music_info_label.setAlignment(Qt.AlignCenter) self.music_info_label.setStyleSheet(""" font-size: 16px; font-weight: bold; color: #2c3e50; margin-bottom: 20px; """) panel_layout.addWidget(self.music_info_label) # 2. 下载进度条(默认隐藏) self.download_progress = QSlider(Qt.Horizontal) self.download_progress.setRange(0, 100) self.download_progress.setValue(0) self.download_progress.setDisabled(True) self.download_progress.setHidden(True) panel_layout.addWidget(self.download_progress) # 3. 播放进度条 self.play_progress = QSlider(Qt.Horizontal) self.play_progress.setRange(0, 100) self.play_progress.setValue(0) self.play_progress.setDisabled(True) self.play_progress.sliderReleased.connect(self.seek_playback) panel_layout.addWidget(self.play_progress) # 4. 时间显示(已播放/总时长) time_layout = QHBoxLayout() self.current_time_label = QLabel("00:00") self.total_time_label = QLabel("00:00") time_layout.addWidget(self.current_time_label) time_layout.addStretch(1) time_layout.addWidget(self.total_time_label) panel_layout.addLayout(time_layout) # 5. 播放控制按钮 control_layout = QHBoxLayout() control_layout.setAlignment(Qt.AlignCenter) self.prev_btn = QPushButton("上一曲") self.prev_btn.setFixedSize(80, 40) self.prev_btn.setDisabled(True) self.prev_btn.clicked.connect(self.play_previous) control_layout.addWidget(self.prev_btn) self.play_pause_btn = QPushButton("播放") self.play_pause_btn.setFixedSize(100, 40) self.play_pause_btn.setDisabled(True) self.play_pause_btn.clicked.connect(self.toggle_play_pause) control_layout.addWidget(self.play_pause_btn) self.next_btn = QPushButton("下一曲") self.next_btn.setFixedSize(80, 40) self.next_btn.setDisabled(True) self.next_btn.clicked.connect(self.play_next) control_layout.addWidget(self.next_btn) panel_layout.addLayout(control_layout) def init_playback_timer(self): """初始化播放进度定时器""" self.playback_timer = QTimer(self) self.playback_timer.setInterval(1000) # 1秒=1000毫秒 self.playback_timer.timeout.connect(self.update_playback_progress) def show_about_dialog(self): """显示关于对话框""" QMessageBox.about( self, "关于落日音乐", "落日音乐 for PC(应用内播放版)\n\n" "版本:1.0.0\n" "核心功能:应用内直接播放、音乐筛选、公告查看" ) # --- 数据加载相关函数 --- def load_data_in_background(self): """后台加载音乐、标签、公告数据""" self.status_bar.showMessage("正在加载数据...") self.set_widgets_enabled(False) # 禁用UI控件 # 启动数据加载线程 self.data_thread = QThread() self.data_worker = DataLoadWorker() self.data_worker.moveToThread(self.data_thread) # 连接线程信号 self.data_thread.started.connect(self.data_worker.run) self.data_worker.music_loaded.connect(self.on_music_data_loaded) self.data_worker.tags_loaded.connect(self.on_tags_data_loaded) self.data_worker.announcements_loaded.connect(self.on_announcements_loaded) self.data_worker.finished.connect(self.data_thread.quit) self.data_worker.finished.connect(lambda: self.set_widgets_enabled(True)) self.data_worker.finished.connect(lambda: self.status_bar.showMessage("数据加载完成")) self.data_thread.start() def set_widgets_enabled(self, enabled): """设置核心UI控件是否可用""" self.search_input.setEnabled(enabled) self.tag_combo.setEnabled(enabled) self.refresh_btn.setEnabled(enabled) self.music_tree.setEnabled(enabled) def on_music_data_loaded(self, music_data): """音乐数据加载完成后的处理""" if not music_data or not music_data.get("success", False): QMessageBox.warning(self, "警告", "音乐数据加载失败!") return self.all_music = music_data["data"] self.filtered_music = self.all_music.copy() self.populate_music_tree() # 填充音乐列表 self.status_bar.showMessage(f"加载完成:共 {len(self.all_music)} 首音乐") def on_tags_data_loaded(self, tags_data): """标签数据加载完成后的处理""" if not tags_data or not tags_data.get("success", False): QMessageBox.warning(self, "警告", "标签数据加载失败!") return tags = tags_data["data"] current_tag = self.tag_combo.currentText() self.tag_combo.clear() self.tag_combo.addItem("所有标签") self.tag_combo.addItems(tags) # 恢复之前选择的标签 if current_tag in tags: self.tag_combo.setCurrentText(current_tag) def on_announcements_loaded(self, announcements_data): """公告数据加载完成后的处理""" self.announcement_panel.clear() if not announcements_data or not announcements_data.get("success", False): self.announcement_panel.setText("公告加载失败或暂无公告") return announcements = announcements_data["data"] for ann in announcements: time_str = ann.get("time", "未知时间") content = ann.get("nr", "无公告内容") self.announcement_panel.append(f"【{time_str}】\n{content}\n" + "-"*50 + "\n") def populate_music_tree(self): """将音乐数据填充到 TreeWidget 列表中""" self.music_tree.clear() for idx, music in enumerate(self.filtered_music): # 格式化时长 duration = music.get("duration", "") try: duration_sec = int(duration) duration_str = f"{duration_sec//60:02d}:{duration_sec%60:02d}" except: duration_str = "未知" # 创建列表项 item = QTreeWidgetItem([ str(music.get("id", "未知")), music.get("title", "未知标题"), music.get("artist", "未知艺术家"), music.get("category", "未知标签"), duration_str ]) # 存储完整音乐信息 item.setData(0, Qt.UserRole, music) # 交替行颜色 if idx % 2 == 0: item.setBackground(0, QBrush(QColor(245, 245, 245))) item.setBackground(1, QBrush(QColor(245, 245, 245))) item.setBackground(2, QBrush(QColor(245, 245, 245))) item.setBackground(3, QBrush(QColor(245, 245, 245))) item.setBackground(4, QBrush(QColor(245, 245, 245))) self.music_tree.addTopLevelItem(item) # --- 音乐筛选相关函数 --- def filter_music_by_search(self): """根据搜索关键词筛选音乐""" self.current_keyword = self.search_input.text().lower() self.apply_music_filters() def filter_music_by_tag(self): """根据标签筛选音乐""" self.current_tag = self.tag_combo.currentText() self.apply_music_filters() def apply_music_filters(self): """应用筛选条件(关键词+标签)""" self.filtered_music = [ music for music in self.all_music if (self.current_tag == "所有标签" or music.get("category", "") == self.current_tag) and ( self.current_keyword in music.get("title", "").lower() or self.current_keyword in music.get("artist", "").lower() or self.current_keyword in music.get("category", "").lower() ) ] self.populate_music_tree() self.highlight_search_keyword() def highlight_search_keyword(self): """高亮音乐列表中的搜索关键词""" if not self.current_keyword: return # 遍历所有列表项,匹配关键词并高亮 for idx in range(self.music_tree.topLevelItemCount()): item = self.music_tree.topLevelItem(idx) for col in range(1, 4): # 只高亮 标题、艺术家、标签 列 text = item.text(col).lower() if self.current_keyword in text: item.setBackground(col, QBrush(QColor(255, 255, 153))) # 浅黄色高亮 else: # 恢复交替行颜色 if idx % 2 == 0: item.setBackground(col, QBrush(QColor(245, 245, 245))) else: item.setBackground(col, QBrush(QColor(255, 255, 255))) # --- 音乐播放相关函数 --- def on_music_double_click(self, item, column): """双击音乐列表项:停止当前播放→下载新音乐→播放""" # 获取当前选中的音乐信息 music = item.data(0, Qt.UserRole) if not music: return # 1. 停止当前播放(如果有) self.stop_playback() # 2. 取消正在进行的下载(如果有) if self.download_thread and self.download_thread.isRunning(): self.download_thread.cancel_download() self.download_thread.wait() # 3. 记录当前播放的音乐信息 self.current_playing_music = music music_id = music.get("id") music_title = music.get("title", "未知音乐") music_artist = music.get("artist", "未知艺术家") # 4. 更新UI显示 self.music_info_label.setText(f"{music_title} - {music_artist}") self.status_bar.showMessage(f"准备播放:{music_title}") # 5. 启动后台下载线程 self.download_thread = AudioDownloadThread(music_id, music_title) # 连接下载信号 self.download_thread.download_progress.connect(self.update_download_progress) self.download_thread.download_finished.connect(self.on_audio_downloaded) self.download_thread.download_failed.connect(self.on_download_failed) # 显示下载进度条 self.download_progress.setHidden(False) self.download_progress.setDisabled(False) # 启动下载 self.download_thread.start() def update_download_progress(self, progress): """更新下载进度条""" self.download_progress.setValue(progress) self.status_bar.showMessage(f"正在下载:{self.current_playing_music['title']}({progress}%)") def on_audio_downloaded(self, music_title, local_file): """音频下载完成,开始播放""" # 隐藏下载进度条 self.download_progress.setHidden(True) self.download_progress.setDisabled(True) self.download_progress.setValue(0) # 记录当前播放的本地文件路径 self.current_playing_file = local_file # 尝试播放音频 try: pygame.mixer.music.load(local_file) pygame.mixer.music.play() self.is_playing = True # 更新UI状态 self.play_pause_btn.setText("暂停") self.play_pause_btn.setEnabled(True) self.prev_btn.setEnabled(True) self.next_btn.setEnabled(True) self.play_progress.setDisabled(False) # 记录总时长并更新时间显示 total_duration = pygame.mixer.Sound(local_file).get_length() # 秒 self.total_duration = total_duration self.total_time_label.setText(self.format_time(total_duration)) # 启动播放进度定时器 self.playback_timer.start() self.status_bar.showMessage(f"正在播放:{music_title}") except Exception as e: self.on_download_failed(f"播放失败:{str(e)}") def on_download_failed(self, error_msg): """下载或播放失败的处理""" # 重置UI self.download_progress.setHidden(True) self.download_progress.setDisabled(True) self.download_progress.setValue(0) self.music_info_label.setText("播放失败") self.status_bar.showMessage(f"错误:{error_msg}") # 弹出错误提示 QMessageBox.warning(self, "播放错误", error_msg) def toggle_play_pause(self): """切换播放/暂停状态""" if not self.current_playing_file: return if self.is_playing: pygame.mixer.music.pause() self.is_playing = False self.play_pause_btn.setText("播放") self.playback_timer.stop() self.status_bar.showMessage(f"已暂停:{self.current_playing_music['title']}") else: pygame.mixer.music.unpause() self.is_playing = True self.play_pause_btn.setText("暂停") self.playback_timer.start() self.status_bar.showMessage(f"继续播放:{self.current_playing_music['title']}") def stop_playback(self): """停止当前播放""" if pygame.mixer.music.get_busy() or self.is_playing: pygame.mixer.music.stop() self.is_playing = False # 重置UI self.playback_timer.stop() self.play_progress.setValue(0) self.current_time_label.setText("00:00") self.total_time_label.setText("00:00") self.play_pause_btn.setText("播放") self.play_pause_btn.setEnabled(False) self.prev_btn.setEnabled(False) self.next_btn.setEnabled(False) # 删除临时音频文件 if self.current_playing_file and os.path.exists(self.current_playing_file): try: os.remove(self.current_playing_file) except: pass # 若文件被占用,忽略删除 self.current_playing_file = "" self.current_playing_music = None def update_playback_progress(self): """更新播放进度条和时间显示""" if not self.current_playing_file or not self.is_playing: return # 获取当前播放位置(秒) current_pos = pygame.mixer.music.get_pos() / 1000 # 毫秒转秒 # 计算进度百分比 progress = int((current_pos / self.total_duration) * 100) progress = min(progress, 100) # 避免超过100% # 更新UI self.play_progress.setValue(progress) self.current_time_label.setText(self.format_time(current_pos)) # 检查是否播放完成 if current_pos >= self.total_duration: self.stop_playback() self.status_bar.showMessage("播放完成:已停止") def seek_playback(self): """拖动进度条跳转播放位置""" if not self.current_playing_file or not self.is_playing: return # 获取进度条百分比对应的播放位置(秒) progress = self.play_progress.value() target_pos = (progress / 100) * self.total_duration # 跳转播放位置 pygame.mixer.music.set_pos(target_pos) # 更新时间显示 self.current_time_label.setText(self.format_time(target_pos)) def play_previous(self): """播放上一曲""" if not self.current_playing_music or len(self.filtered_music) <= 1: return # 找到当前播放音乐在筛选列表中的索引 current_idx = -1 current_id = self.current_playing_music.get("id") for idx, music in enumerate(self.filtered_music): if str(music.get("id")) == str(current_id): current_idx = idx break # 播放前一项(循环到最后一项如果当前是第一项) if current_idx == 0: target_idx = len(self.filtered_music) - 1 else: target_idx = current_idx - 1 # 触发双击播放 target_item = self.music_tree.topLevelItem(target_idx) self.on_music_double_click(target_item, 0) def play_next(self): """播放下一曲""" if not self.current_playing_music or len(self.filtered_music) <= 1: return # 找到当前播放音乐在筛选列表中的索引 current_idx = -1 current_id = self.current_playing_music.get("id") for idx, music in enumerate(self.filtered_music): if str(music.get("id")) == str(current_id): current_idx = idx break # 播放后一项(循环到第一项如果当前是最后一项) if current_idx == len(self.filtered_music) - 1: target_idx = 0 else: target_idx = current_idx + 1 # 触发双击播放 target_item = self.music_tree.topLevelItem(target_idx) self.on_music_double_click(target_item, 0) @staticmethod def format_time(seconds): """将秒数格式化为 分:秒""" minutes = int(seconds // 60) secs = int(seconds % 60) return f"{minutes:02d}:{secs:02d}" def closeEvent(self, event): """窗口关闭时的清理操作""" # 停止播放 self.stop_playback() # 退出 pygame pygame.mixer.quit() # 关闭线程 if hasattr(self, "download_thread") and self.download_thread.isRunning(): self.download_thread.cancel_download() self.download_thread.wait() event.accept() # --- 程序入口 --- if __name__ == "__main__": # 解决 PyQt5 中文显示问题 QApplication.setAttribute(Qt.AA_EnableHighDpiScaling) app = QApplication(sys.argv) # 设置全局样式 app.setStyle("Fusion") # 启动应用 window = SunsetMusicApp() window.show() sys.exit(app.exec_())