Files
Sunset-Music-ForPC/Beta-0.1.2.py
2025-09-25 12:47:28 +00:00

726 lines
28 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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_())