@top777 首先感谢
[color=]top777
的下载歌词及封面的代码,让这个播放Mp3的小软件有了质的飞越。
现在软件启动时会自动下载播放列表中歌曲的歌词及封面并内嵌(如有则跳过),所以,你可能放着放着就发现歌曲有歌词和封面了(第一次启动时因为还没有添加歌曲列表,添加后播放列表中右键手动下载)。
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------一直想做一个界面好看点的音乐播放器,今天在GPT-4.1的神功下,小半天的时间总算搞好了。有旋转的黑胶唱片封面、浮动的歌词显示。(程序读取歌曲内嵌的封面和歌词及目录下同名LRC。)
全AI写的,我没有写一句程序,大家可以随意魔改自己想要的功能。
目录里有编译好的EXE文件,也有源码。https://www.alipan.com/s/w2eqoL2fZbw
https://pan.baidu.com/s/1hj6i3hBpidwD7NXy2GMh0Q?pwd=uky8 提取码: uky8
----------------------------------------------------------------------------------------------------------------------
[Python] 纯文本查看 复制代码import sys
import os
from PyQt5.QtWidgets import (
QApplication, QWidget, QLabel, QPushButton, QSlider, QHBoxLayout, QVBoxLayout, QGridLayout, QFileDialog, QListWidget, QListWidgetItem, QMenu
)
from PyQt5.QtCore import Qt, QTimer, QUrl, QByteArray, pyqtSignal
from PyQt5.QtGui import QPixmap, QPainter, QTransform, QPainterPath, QFont, QColor, QLinearGradient, QBrush, QPen, QCursor
from mutagen.mp3 import MP3
from mutagen.id3 import ID3, APIC
from PyQt5.QtMultimedia import QMediaPlayer, QMediaContent
import vlc
import re
def resource_path(relative_path):
if hasattr(sys, '_MEIPASS'):
return os.path.join(sys._MEIPASS, relative_path)
return os.path.join(os.path.abspath("."), relative_path)
# 旋转封面控件
class RotatingCover(QLabel):
def __init__(self, song_path, default_cover="fm.png"):
super().__init__()
self.angle = 0
self.pixmap = self.load_cover(song_path, default_cover)
if self.pixmap.isNull():
self.setText("未找到封面")
self.setStyleSheet("color: #fff; background: #666; border-radius: 125px; font-size: 20px;")
self.timer = QTimer(self)
self.timer.timeout.connect(self.rotate)
self.timer.start(50) # 旋转速度
def load_cover(self, song_path, default_cover):
# 1. 尝试同名jpg
base, _ = os.path.splitext(song_path)
jpg_path = base + ".jpg"
if os.path.exists(jpg_path):
return QPixmap(jpg_path)
# 2. 尝试MP3内嵌封面
try:
audio = MP3(song_path, ID3=ID3)
for tag in audio.tags.values():
if isinstance(tag, APIC):
ba = QByteArray(tag.data)
pixmap = QPixmap()
pixmap.loadFromData(ba)
if not pixmap.isNull():
return pixmap
except Exception as e:
pass
# 3. 默认封面
if os.path.exists(default_cover):
return QPixmap(default_cover)
return QPixmap() # 空pixmap
def rotate(self):
if self.pixmap.isNull():
return
self.angle = (self.angle + 2) % 360
target_size = 250
# 只旋转封面
cover_scaled = self.pixmap.scaled(target_size, target_size, Qt.KeepAspectRatioByExpanding, Qt.SmoothTransformation)
cover_rotated = cover_scaled.transformed(QTransform().rotate(self.angle), Qt.SmoothTransformation)
# 裁剪为圆形
cover_circle = QPixmap(target_size, target_size)
cover_circle.fill(Qt.transparent)
painter = QPainter(cover_circle)
painter.setRenderHint(QPainter.Antialiasing)
path = QPainterPath()
path.addEllipse(0, 0, target_size, target_size)
painter.setClipPath(path)
x = (target_size - cover_rotated.width()) // 2
y = (target_size - cover_rotated.height()) // 2
painter.drawPixmap(x, y, cover_rotated)
painter.end()
self.setPixmap(cover_circle)
def setCover(self, song_path, default_cover="fm.png"):
self.pixmap = self.load_cover(song_path, default_cover)
class CoverWidget(QWidget):
def __init__(self, default_cover="fm.png"):
super().__init__()
self.setFixedSize(250, 250)
self.bg_pixmap = QPixmap(default_cover) if os.path.exists(default_cover) else QPixmap()
self.cover_pixmap = QPixmap()
self.angle = 0
self.timer = QTimer(self)
self.timer.timeout.connect(self.rotate)
self.timer.start(50)
self.rotate()
self.default_cover = default_cover
def rotate(self):
self.angle = (self.angle + 2) % 360
self.update()
def setCover(self, song_path):
pixmap = self.load_cover(song_path, self.default_cover)
self.cover_pixmap = pixmap
self.update()
def load_cover(self, song_path, default_cover):
if not song_path or not os.path.exists(song_path):
return QPixmap(default_cover) if os.path.exists(default_cover) else QPixmap()
# 1. 尝试同名jpg
base, _ = os.path.splitext(song_path)
jpg_path = base + ".jpg"
if os.path.exists(jpg_path):
return QPixmap(jpg_path)
# 2. 尝试MP3内嵌封面
try:
audio = MP3(song_path, ID3=ID3)
for tag in audio.tags.values():
if isinstance(tag, APIC):
ba = QByteArray(tag.data)
pixmap = QPixmap()
pixmap.loadFromData(ba)
if not pixmap.isNull():
return pixmap
except Exception as e:
pass
# 3. 默认封面
if os.path.exists(default_cover):
return QPixmap(default_cover)
return QPixmap() # 空pixmap
def paintEvent(self, event):
painter = QPainter(self)
painter.setRenderHint(QPainter.Antialiasing)
# 1. 画旋转后的 fm.png
if not self.bg_pixmap.isNull():
bg = self.bg_pixmap.scaled(250, 250, Qt.KeepAspectRatio, Qt.SmoothTransformation)
# 以中心为原点旋转
painter.save()
painter.translate(self.width() // 2, self.height() // 2)
painter.rotate(self.angle)
painter.translate(-bg.width() // 2, -bg.height() // 2)
painter.drawPixmap(0, 0, bg)
painter.restore()
# 2. 画旋转后的封面(内切圆,直径130,居中)
if not self.cover_pixmap.isNull():
size = 130
# 1. 先裁剪为正方形
src = self.cover_pixmap
w, h = src.width(), src.height()
if w != h:
side = min(w, h)
x = (w - side) // 2
y = (h - side) // 2
src = src.copy(x, y, side, side)
# 2. 缩放
cover = src.scaled(size, size, Qt.KeepAspectRatio, Qt.SmoothTransformation)
# 3. 裁剪为圆形
cover_circle = QPixmap(size, size)
cover_circle.fill(Qt.transparent)
p = QPainter(cover_circle)
p.setRenderHint(QPainter.Antialiasing)
path = QPainterPath()
path.addEllipse(0, 0, size, size)
p.setClipPath(path)
p.drawPixmap(0, 0, cover)
p.end()
# 4. 以中心为原点旋转
painter.save()
painter.translate(self.width() // 2, self.height() // 2)
painter.rotate(self.angle)
painter.translate(-size // 2, -size // 2)
painter.drawPixmap(0, 0, cover_circle)
painter.restore()
painter.end()
# 动态歌词控件(简化版)
class LyricLabel(QWidget):
def __init__(self):
super().__init__()
self.lyrics = [] # [(time, text), ...]
self.current_index = 0
self.setMinimumHeight(120)
self.setStyleSheet("background: transparent;")
self.color_main = QColor("#fff")
self.color_other = QColor("#aaa")
def setDemoText(self, text):
# 只显示一行小字号的提示
self.lyrics = []
self.current_index = 0
self.demo_text = text
self.update()
def setLyrics(self, lyrics):
self.lyrics = lyrics
self.current_index = 0
self.demo_text = None
self.update()
def setCurrentTime(self, cur_time):
self.cur_time = cur_time # 记录当前时间
idx = 0
for i, (t, _) in enumerate(self.lyrics):
if cur_time >= t:
idx = i
else:
break
if idx != self.current_index:
self.current_index = idx
self.update()
else:
self.update() # 即使index没变,也要刷新实现平滑
def paintEvent(self, event):
painter = QPainter(self)
painter.setRenderHint(QPainter.Antialiasing)
w, h = self.width(), self.height()
# 计算要显示的歌词行
lines = []
for offset in range(-2, 3):
idx = self.current_index + offset
if 0 10:
font_main = QFont("微软雅黑", main_font_size, QFont.Bold)
painter.setFont(font_main)
rect = painter.fontMetrics().boundingRect(main_text)
if rect.width() cur_line_time:
percent = min(max((cur_time - cur_line_time) / (next_line_time - cur_line_time), 0), 1)
else:
percent = 0
# 歌词整体Y轴平滑上移
scroll_offset = -percent * line_height
# 歌词整体垂直居中
start_y = (h - total_lines * line_height) // 2 + scroll_offset
for i, (offset, text) in enumerate(lines):
y = start_y + i * line_height + line_height // 2
if offset == 0:
painter.setFont(font_main)
# ====== 彩虹色高亮 ======
grad = QLinearGradient(0, y-line_height//2, w, y+line_height//2)
for j in range(7):
grad.setColorAt(j/6, QColor.fromHsv(int((j*60 + (cur_time*60)%360)%360), 255, 255))
painter.setPen(QPen(QBrush(grad), 0))
else:
painter.setFont(font_other)
painter.setPen(self.color_other)
painter.drawText(0, int(y-line_height//2), w, line_height, Qt.AlignHCenter | Qt.AlignVCenter, text)
class PlaylistWidget(QListWidget):
favSong = pyqtSignal(str)
def __init__(self, parent=None):
super().__init__(parent)
self.setStyleSheet("font-size:18px;background:#222;color:#fff;")
self.setDragDropMode(QListWidget.InternalMove)
self.setContextMenuPolicy(Qt.CustomContextMenu)
self.customContextMenuRequested.connect(self.show_menu)
self.model().rowsMoved.connect(self.on_rows_moved)
def show_menu(self, pos):
menu = QMenu(self)
act_fav = menu.addAction("收藏该歌曲")
act_del = menu.addAction("删除选中项")
act_clear = menu.addAction("清空列表")
action = menu.exec_(self.mapToGlobal(pos))
if action == act_fav:
self.fav_selected()
elif action == act_del:
self.delete_selected()
elif action == act_clear:
self.clear_playlist()
def delete_selected(self):
for item in self.selectedItems():
row = self.row(item)
self.takeItem(row)
if hasattr(self.parent(), "sync_song_list"):
self.parent().sync_song_list()
def clear_playlist(self):
self.clear()
if hasattr(self.parent(), "sync_song_list"):
self.parent().sync_song_list()
def on_rows_moved(self, *args):
if hasattr(self.parent(), "sync_song_list"):
self.parent().sync_song_list()
def fav_selected(self):
for item in self.selectedItems():
song = item.toolTip()
self.favSong.emit(song)
class FloatingLyricWindow(QWidget):
EDGE_MARGIN = 8 # 边缘判定宽度
def __init__(self):
super().__init__()
self.setWindowFlags(
Qt.FramelessWindowHint |
Qt.WindowStaysOnTopHint |
Qt.Tool
)
self.setAttribute(Qt.WA_TranslucentBackground)
self.setWindowOpacity(0.85) # 半透明
self.lyric = FloatingLyricLabel() # 只显示2行
layout = QVBoxLayout()
layout.setContentsMargins(16, 16, 16, 16)
layout.addWidget(self.lyric)
self.setLayout(layout)
self.resize(800, 100)
desktop = QApplication.desktop()
self.move(
(desktop.width() - self.width()) // 2,
desktop.height() - 150
)
# 拖动和缩放相关
self._move_drag = False
self._move_DragPosition = None
self._resize_drag = False
self._resize_dir = None
def paintEvent(self, event):
painter = QPainter(self)
painter.setRenderHint(QPainter.Antialiasing)
rect = self.rect()
color = QColor(30, 30, 30, 200) # 深色半透明
painter.setBrush(color)
painter.setPen(Qt.NoPen)
painter.drawRoundedRect(rect, 18, 18)
def mousePressEvent(self, event):
if event.button() == Qt.LeftButton:
pos = event.pos()
margin = self.EDGE_MARGIN
rect = self.rect()
# 判断是否在边缘
if pos.x() rect.width() - margin and pos.y() rect.height() - margin:
self._resize_drag = True
self._resize_dir = 'bottomleft'
elif pos.x() > rect.width() - margin and pos.y() > rect.height() - margin:
self._resize_drag = True
self._resize_dir = 'bottomright'
elif pos.x() rect.width() - margin:
self._resize_drag = True
self._resize_dir = 'right'
elif pos.y() rect.height() - margin:
self._resize_drag = True
self._resize_dir = 'bottom'
else:
self._move_drag = True
self._move_DragPosition = event.globalPos() - self.pos()
event.accept()
def mouseMoveEvent(self, event):
if self._move_drag and event.buttons() == Qt.LeftButton:
self.move(event.globalPos() - self._move_DragPosition)
event.accept()
elif self._resize_drag and event.buttons() == Qt.LeftButton:
gpos = event.globalPos()
geo = self.geometry()
minw, minh = 200, 50
if self._resize_dir == 'left':
diff = gpos.x() - geo.x()
neww = geo.width() - diff
if neww > minw:
geo.setLeft(gpos.x())
elif self._resize_dir == 'right':
neww = gpos.x() - geo.x()
if neww > minw:
geo.setWidth(neww)
elif self._resize_dir == 'top':
diff = gpos.y() - geo.y()
newh = geo.height() - diff
if newh > minh:
geo.setTop(gpos.y())
elif self._resize_dir == 'bottom':
newh = gpos.y() - geo.y()
if newh > minh:
geo.setHeight(newh)
elif self._resize_dir == 'topleft':
diffx = gpos.x() - geo.x()
diffy = gpos.y() - geo.y()
neww = geo.width() - diffx
newh = geo.height() - diffy
if neww > minw:
geo.setLeft(gpos.x())
if newh > minh:
geo.setTop(gpos.y())
elif self._resize_dir == 'topright':
diffy = gpos.y() - geo.y()
neww = gpos.x() - geo.x()
newh = geo.height() - diffy
if neww > minw:
geo.setWidth(neww)
if newh > minh:
geo.setTop(gpos.y())
elif self._resize_dir == 'bottomleft':
diffx = gpos.x() - geo.x()
neww = geo.width() - diffx
newh = gpos.y() - geo.y()
if neww > minw:
geo.setLeft(gpos.x())
if newh > minh:
geo.setHeight(newh)
elif self._resize_dir == 'bottomright':
neww = gpos.x() - geo.x()
newh = gpos.y() - geo.y()
if neww > minw:
geo.setWidth(neww)
if newh > minh:
geo.setHeight(newh)
self.setGeometry(geo)
event.accept()
# 无论是否拖动,都要设置光标
self.update_cursor(event.pos())
def mouseReleaseEvent(self, event):
self._move_drag = False
self._resize_drag = False
self._resize_dir = None
def setLyrics(self, lyrics):
self.lyric.setLyrics(lyrics)
def setCurrentTime(self, time):
self.lyric.setCurrentTime(time)
def update_cursor(self, pos):
margin = self.EDGE_MARGIN
rect = self.rect()
if (pos.x() rect.width() - margin and pos.y() > rect.height() - margin):
self.setCursor(Qt.SizeFDiagCursor)
elif (pos.x() > rect.width() - margin and pos.y() rect.height() - margin):
self.setCursor(Qt.SizeBDiagCursor)
elif pos.x() rect.width() - margin:
self.setCursor(Qt.SizeHorCursor)
elif pos.y() rect.height() - margin:
self.setCursor(Qt.SizeVerCursor)
else:
self.setCursor(Qt.ArrowCursor)
def enterEvent(self, event):
self.update_cursor(self.mapFromGlobal(QCursor.pos()))
def leaveEvent(self, event):
self.setCursor(Qt.ArrowCursor)
class FloatingLyricLabel(LyricLabel):
def paintEvent(self, event):
painter = QPainter(self)
painter.setRenderHint(QPainter.Antialiasing)
w, h = self.width(), self.height()
# 只显示当前行和下一行,且排除空行
lines = []
idx = self.current_index
# 找到当前行及下一个非空行
count = 0
while idx 10:
font = QFont("微软雅黑", font_size, QFont.Bold if bold else QFont.Normal)
painter.setFont(font)
rect = painter.fontMetrics().boundingRect(text)
if rect.width() = 0:
self.current_index = row
state = self.vlc_player.get_state()
if state in (vlc.State.Playing, vlc.State.Buffering):
self.vlc_player.pause()
elif state == vlc.State.Paused:
self.vlc_player.play() # 继续播放
elif state in (vlc.State.Stopped, vlc.State.Ended, vlc.State.NothingSpecial):
self.play_song_by_index(row)
def play_song_by_index(self, idx):
if 0 0:
pos = self.slider.value() / 100
self.vlc_player.set_time(int(total * pos))
def on_slider_moved(self, value):
total = self.vlc_player.get_length()
if total > 0:
cur_time = int(total * value / 100)
self.time_label_left.setText(self.ms_to_mmss(cur_time))
def update_progress(self):
if self.vlc_player.is_playing() and not self.slider_is_pressed:
total = self.vlc_player.get_length()
cur = self.vlc_player.get_time()
if total > 0:
percent = int(cur / total * 100)
self.slider.setValue(percent)
self.time_label_left.setText(self.ms_to_mmss(cur))
self.time_label_right.setText(self.ms_to_mmss(total))
else:
self.slider.setValue(0)
self.time_label_right.setText("--:--")
elif not self.vlc_player.is_playing():
if self.vlc_player.get_state() == vlc.State.Ended:
if self.shuffle_mode:
import random
if self.song_list:
# 避免重复播放当前曲目
candidates = [i for i in range(len(self.song_list)) if i != self.current_index]
if candidates:
next_index = random.choice(candidates)
else:
next_index = self.current_index
self.play_song_by_index(next_index)
else:
self.play_next()
# 图标联动
state = self.vlc_player.get_state()
if state in (vlc.State.Playing, vlc.State.Buffering):
self.btn_play.setText("⏸")
else:
self.btn_play.setText("▶")
if hasattr(self, "lyric") and hasattr(self, "lyrics_parsed"):
cur = self.vlc_player.get_time() / 1000
self.lyric.setCurrentTime(cur)
self.floating_lyric.setCurrentTime(cur) # 同步到浮动歌词
def ms_to_mmss(self, ms):
s = int(ms // 1000)
m = s // 60
s = s % 60
return f"{m:02d}:{s:02d}"
def save_playlist(self):
self.sync_song_list()
file_path, _ = QFileDialog.getSaveFileName(self, "保存播放列表", "", "播放列表文件 (*.m3u *.txt);;所有文件 (*)")
if file_path:
try:
with open(file_path, "w", encoding="utf-8") as f:
for song in self.song_list:
f.write(song + "\n")
except Exception as e:
print("保存失败:", e)
def load_playlist(self):
file_path, _ = QFileDialog.getOpenFileName(self, "加载播放列表", "", "播放列表文件 (*.m3u *.txt);;所有文件 (*)")
if file_path:
try:
with open(file_path, "r", encoding="utf-8") as f:
lines = [line.strip() for line in f if line.strip()]
self.song_list.clear()
self.list_widget.clear()
for song in lines:
if os.path.exists(song):
self.song_list.append(song)
item = QListWidgetItem(os.path.basename(song))
item.setToolTip(song)
self.list_widget.addItem(item)
if self.song_list:
self.list_widget.setCurrentRow(0)
self.play_song_by_index(0)
# 保存最后一次歌单路径
with open("last_playlist.txt", "w", encoding="utf-8") as f:
f.write(file_path)
except Exception as e:
print("加载失败:", e)
def sync_song_list(self):
self.song_list = []
for i in range(self.list_widget.count()):
item = self.list_widget.item(i)
if item and item.toolTip():
self.song_list.append(item.toolTip())
def toggle_shuffle_mode(self):
self.shuffle_mode = not self.shuffle_mode
self.update_shuffle_button_style()
print(f"切换后模式: {self.shuffle_mode}, 按钮文本: {self.btn_loop.text()}")
def stop_play(self):
self.vlc_player.stop()
self.timer.stop()
self.slider.setValue(0)
self.time_label_left.setText("00:00")
self.time_label_right.setText("--:--")
self.lyric.setDemoText(self.default_lyric_text)
self.append_to_fav(self.song_list[self.current_index])
def update_shuffle_button_style(self):
if self.shuffle_mode:
self.btn_loop.setText("🔀")
else:
self.btn_loop.setText("🔁")
self.btn_loop.update() # 强制刷新按钮
def load_lrc(self, song_path):
lrc_path = os.path.splitext(song_path)[0] + ".lrc"
if os.path.exists(lrc_path):
with open(lrc_path, encoding="utf-8") as f:
return f.read()
return None
def load_embedded_lyric(self, song_path):
try:
audio = MP3(song_path, ID3=ID3)
for tag in audio.tags.values():
if tag.FrameID == "USLT":
return tag.text
except Exception:
pass
return None
def toggle_floating_lyric(self):
if self.floating_lyric_visible:
self.floating_lyric.hide()
self.floating_lyric_visible = False
self.btn_float_lyric.setStyleSheet(
"font-size: 24px; background: #333; color: #fff; border-radius: 24px; font-weight: bold;"
)
else:
self.floating_lyric.show()
self.floating_lyric_visible = True
self.btn_float_lyric.setStyleSheet(
"font-size: 24px; background: #09f; color: #fff; border-radius: 24px; font-weight: bold;"
)
def toggle_mute(self):
self.is_muted = not self.is_muted
self.vlc_player.audio_set_mute(self.is_muted)
if self.is_muted:
self.btn_mute.setText("🔇")
self.btn_mute.setStyleSheet("font-size: 24px; background: #09f; color: #fff; border-radius: 24px; font-weight: bold;")
else:
self.btn_mute.setText("🔊")
self.btn_mute.setStyleSheet("font-size: 24px; background: #333; color: #fff; border-radius: 24px; font-weight: bold;")
def append_to_fav(self, song):
fav_path = os.path.abspath("收藏歌单.m3u")
try:
need_header = not os.path.exists(fav_path)
if os.path.exists(fav_path):
with open(fav_path, "r", encoding="utf-8") as f:
lines = [line.strip() for line in f if line.strip()]
if song in lines:
return
with open(fav_path, "a", encoding="utf-8") as f:
if need_header:
f.write("#EXTM3U\n")
f.write(song + "\n")
print(f"已收藏到: {fav_path}") # 调试用
except Exception as e:
print("收藏失败:", e)
def load_last_playlist(self):
try:
if os.path.exists("last_playlist.txt"):
with open("last_playlist.txt", "r", encoding="utf-8") as f:
file_path = f.read().strip()
if file_path and os.path.exists(file_path):
with open(file_path, "r", encoding="utf-8") as f:
lines = [line.strip() for line in f if line.strip()]
self.song_list.clear()
self.list_widget.clear()
for song in lines:
if os.path.exists(song):
self.song_list.append(song)
item = QListWidgetItem(os.path.basename(song))
item.setToolTip(song)
self.list_widget.addItem(item)
if self.song_list:
self.list_widget.setCurrentRow(0)
self.play_song_by_index(0)
except Exception as e:
print("自动加载上次歌单失败:", e)
def parse_lrc(lrc_text):
pattern = re.compile(r"\[(\d+):(\d+)(?:\.(\d+))?\](.*)")
result = []
for line in lrc_text.splitlines():
m = pattern.match(line)
if m:
minute = int(m.group(1))
second = int(m.group(2))
ms = int(m.group(3) or 0)
time = minute * 60 + second + ms / 100 if ms else minute * 60 + second
text = m.group(4).strip()
result.append((time, text))
result.sort()
return result
if __name__ == "__main__":
app = QApplication(sys.argv)
player = MP3Player()
player.show()
print("当前工作目录:", os.getcwd())
sys.exit(app.exec_())