[Python] 纯文本查看 复制代码
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
DiDiPlayer v13 - 千千静听风格播放器
新增:歌词搜索结果选择、平滑滚动、频谱颜色渐变
"""
import tkinter as tk
from tkinter import ttk, filedialog, messagebox
import pygame
import os
import threading
import json
import random
import urllib.request
import urllib.parse
import re
from pathlib import Path
from dataclasses import dataclass
from typing import List, Optional, Dict, Tuple
import struct
import wave
import math
import zlib
import hashlib
import time
import colorsys
# ===== 数据模型 =====
@dataclass
class Song:
"""歌曲"""
path: str
title: str = ""
artist: str = ""
album: str = ""
duration: int = 0 # 秒
def __post_init__(self):
if not self.title:
self.title = Path(self.path).stem
self._load_metadata()
def _load_metadata(self):
"""加载元数据"""
try:
if self.path.lower().endswith('.wav'):
with wave.open(self.path, 'rb') as wf:
frames = wf.getnframes()
rate = wf.getframerate()
self.duration = int(frames / rate)
elif self.path.lower().endswith(('.mp3', '.ogg', '.flac', '.m4a')):
if pygame.mixer.get_init():
try:
snd = pygame.mixer.Sound(self.path)
self.duration = int(snd.get_length())
except:
pass
except:
pass
@dataclass
class LyricLine:
"""歌词行"""
time: float # 秒
text: str
@dataclass
class LyricSearchResult:
"""歌词搜索结果"""
title: str
artist: str
source: str # 来源平台
lyric_id: str
lyric_url: Optional[str] = None
# ===== 歌词解析器 =====
class LyricParser:
"""多格式歌词解析器"""
@staticmethod
def parse(content: bytes, format_hint: str = None) -> List[LyricLine]:
"""自动识别格式并解析歌词"""
text = None
for encoding in ['utf-8', 'gbk', 'gb18030', 'utf-16']:
try:
text = content.decode(encoding)
break
except:
continue
if text is None:
return []
if format_hint == 'krc' or content[:4] == b'krc1':
return LyricParser._parse_krc(content)
elif format_hint == 'qrc' or '<Lyric_1 LyricType="1"' in text:
return LyricParser._parse_qrc(text)
elif format_hint == 'lrc' or '[' in text and ']' in text:
return LyricParser._parse_lrc(text)
else:
return LyricParser._parse_txt(text)
@staticmethod
def _parse_lrc(content: str) -> List[LyricLine]:
"""解析 LRC 格式"""
lines = []
pattern = r'\[(\d{1,2}):(\d{1,2})(?:[.:](\d{1,3}))?\](.*)'
for line in content.split('\n'):
match = re.match(pattern, line.strip())
if match:
m, s, ms, text = match.groups()
time = int(m) * 60 + int(s) + (int(ms or 0) / 100)
if text.strip():
lines.append(LyricLine(time, text.strip()))
return sorted(lines, key=lambda x: x.time)
@staticmethod
def _parse_krc(content: bytes) -> List[LyricLine]:
"""解析酷狗 KRC 格式"""
lines = []
try:
if content[:4] == b'krc1':
compressed = content[4:]
try:
decompressed = zlib.decompress(compressed, -15)
text = decompressed.decode('utf-8', errors='ignore')
except:
return []
else:
text = content.decode('utf-8', errors='ignore')
pattern = r'\[(\d+),(\d+)\]([^\[]+)'
for match in re.finditer(pattern, text):
minute = int(match.group(1)) // 60000
second = (int(match.group(1)) % 60000) // 1000
ms = int(match.group(1)) % 1000
time = minute * 60 + second + ms / 1000
lyric_text = match.group(3)
lyric_text = re.sub(r'<\d+,\d+,\d+>', '', lyric_text)
if lyric_text.strip():
lines.append(LyricLine(time, lyric_text.strip()))
except Exception as e:
print(f"KRC 解析错误: {e}")
return sorted(lines, key=lambda x: x.time)
@staticmethod
def _parse_qrc(content: str) -> List[LyricLine]:
"""解析QQ音乐 QRC 格式"""
lines = []
try:
pattern = r'<!\[CDATA\[(.*?)\]\]>'
match = re.search(pattern, content, re.DOTALL)
if match:
lyric_content = match.group(1)
time_pattern = r'\((\d+),(\d+)\)([^(]*)'
for m in re.finditer(time_pattern, lyric_content):
time = int(m.group(1)) / 1000
text = m.group(3)
if text.strip():
lines.append(LyricLine(time, text.strip()))
except Exception as e:
print(f"QRC 解析错误: {e}")
return sorted(lines, key=lambda x: x.time)
@staticmethod
def _parse_txt(content: str) -> List[LyricLine]:
"""解析纯文本格式"""
lines = []
text_lines = [l.strip() for l in content.split('\n') if l.strip()]
for i, text in enumerate(text_lines):
lines.append(LyricLine(i * 5, text))
return lines
# ===== 多源歌词搜索 =====
class MultiSourceLyricSearch:
"""多源歌词搜索引擎"""
CACHE_DIR = Path(os.environ.get('LOCALAPPDATA', '.')) / 'DiDiPlayer' / 'lyrics'
CHANNEL_STEREO = "stereo"
CHANNEL_LEFT = "left"
CHANNEL_RIGHT = "right"
@classmethod
def ensure_cache_dir(cls):
cls.CACHE_DIR.mkdir(parents=True, exist_ok=True)
@classmethod
def search_all_sources(cls, title: str, artist: str = "") -> Tuple[List[LyricLine], str]:
"""从所有源搜索歌词,返回歌词和来源"""
cls.ensure_cache_dir()
cache_key = hashlib.md5(f"{title}_{artist}".encode()).hexdigest()
cache_file = cls.CACHE_DIR / f"{cache_key}.lrc"
if cache_file.exists():
try:
with open(cache_file, 'rb') as f:
return LyricParser.parse(f.read(), 'lrc'), "缓存"
except:
pass
sources = [
("网易云", cls._search_netease),
("QQ音乐", cls._search_qq),
("酷狗", cls._search_kugou),
("酷我", cls._search_kuwo),
("咪咕", cls._search_migu),
]
for source_name, search_func in sources:
try:
lyrics = search_func(title, artist)
if lyrics:
try:
with open(cache_file, 'w', encoding='utf-8') as f:
for line in lyrics:
m, s = int(line.time // 60), int(line.time % 60)
ms = int((line.time % 1) * 100)
f.write(f"[{m:02d}:{s:02d}.{ms:02d}]{line.text}\n")
except:
pass
return lyrics, source_name
except Exception as e:
print(f"{source_name}搜索失败: {e}")
continue
return [], ""
@classmethod
def search_and_get_results(cls, title: str, artist: str = "") -> List[LyricSearchResult]:
"""搜索并返回所有结果供用户选择"""
results = []
for search_func in [cls._search_netease_results, cls._search_qq_results, cls._search_kugou_results]:
try:
r = search_func(title, artist)
results.extend(r)
except:
pass
return results[:15] # 最多返回15个结果
@classmethod
def get_lyric_by_result(cls, result: LyricSearchResult) -> List[LyricLine]:
"""根据搜索结果获取歌词"""
if result.source == "网易云":
return cls._get_netease_lyric(result.lyric_id)
elif result.source == "QQ音乐":
return cls._get_qq_lyric(result.lyric_id, result.lyric_url or "")
elif result.source == "酷狗":
return cls._get_kugou_lyric(result.lyric_id)
return []
# 网易云
@classmethod
def _search_netease(cls, title: str, artist: str) -> List[LyricLine]:
try:
url = "https://music.163.com/api/search/get/?" + urllib.parse.urlencode({
's': f"{title} {artist}".strip(), 'type': '1', 'limit': '1'
})
req = urllib.request.Request(url, headers={
'User-Agent': 'Mozilla/5.0', 'Referer': 'https://music.163.com/'
})
with urllib.request.urlopen(req, timeout=8) as resp:
data = json.loads(resp.read().decode('utf-8'))
if data.get('code') != 200: return []
songs = data.get('result', {}).get('songs', [])
if not songs: return []
return cls._get_netease_lyric(str(songs[0]['id']))
except Exception as e:
print(f"网易云搜索失败: {e}")
return []
@classmethod
def _search_netease_results(cls, title: str, artist: str) -> List[LyricSearchResult]:
results = []
try:
url = "https://music.163.com/api/search/get/?" + urllib.parse.urlencode({
's': f"{title} {artist}".strip(), 'type': '1', 'limit': '5'
})
req = urllib.request.Request(url, headers={
'User-Agent': 'Mozilla/5.0', 'Referer': 'https://music.163.com/'
})
with urllib.request.urlopen(req, timeout=8) as resp:
data = json.loads(resp.read().decode('utf-8'))
if data.get('code') != 200: return []
for song in data.get('result', {}).get('songs', []):
results.append(LyricSearchResult(
title=song.get('name', ''),
artist=', '.join([a.get('name', '') for a in song.get('artists', [])]),
source="网易云",
lyric_id=str(song['id'])
))
except Exception as e:
print(f"网易云搜索失败: {e}")
return results
@classmethod
def _get_netease_lyric(cls, song_id: str) -> List[LyricLine]:
try:
url = f"https://music.163.com/api/song/lyric?id={song_id}&lv=1"
req = urllib.request.Request(url, headers={
'User-Agent': 'Mozilla/5.0', 'Referer': 'https://music.163.com/'
})
with urllib.request.urlopen(req, timeout=8) as resp:
data = json.loads(resp.read().decode('utf-8'))
if data.get('code') != 200: return []
lrc = data.get('lrc', {}).get('lyric', '')
return LyricParser._parse_lrc(lrc) if lrc else []
except:
return []
# QQ音乐
@classmethod
def _search_qq(cls, title: str, artist: str) -> List[LyricLine]:
try:
results = cls._search_qq_results(title, artist)
if results:
return cls._get_qq_lyric(results[0].lyric_id, results[0].lyric_url or "")
except:
pass
return []
@classmethod
def _search_qq_results(cls, title: str, artist: str) -> List[LyricSearchResult]:
results = []
try:
url = "https://c.y.qq.com/soso/fcgi-bin/client_search_cp?" + urllib.parse.urlencode({
'w': f"{title} {artist}".strip(), 'format': 'json', 'n': '5', 'p': '1'
})
req = urllib.request.Request(url, headers={
'User-Agent': 'Mozilla/5.0', 'Referer': 'https://y.qq.com/'
})
with urllib.request.urlopen(req, timeout=8) as resp:
data = json.loads(resp.read().decode('utf-8'))
if data.get('code') != 0: return []
for song in data.get('data', {}).get('song', {}).get('list', []):
results.append(LyricSearchResult(
title=song.get('songname', ''),
artist=', '.join([s.get('name', '') for s in song.get('singer', [])]),
source="QQ音乐",
lyric_id=song.get('songmid', ''),
lyric_url=f"https://c.y.qq.com/lyric/fcgi-bin/fcg_query_lyric_new.fcg?songmid={song.get('songmid', '')}"
))
except:
pass
return results
@classmethod
def _get_qq_lyric(cls, song_mid: str, lyric_url: str) -> List[LyricLine]:
try:
import base64
req = urllib.request.Request(lyric_url, headers={
'User-Agent': 'Mozilla/5.0', 'Referer': 'https://y.qq.com/'
})
with urllib.request.urlopen(req, timeout=8) as resp:
data = resp.read().decode('utf-8', errors='ignore')
match = re.search(r'MusicJsonCallback\((.*)\)', data, re.DOTALL)
if match:
json_data = json.loads(match.group(1))
lrc = json_data.get('lyric', '')
if lrc:
lrc = base64.b64decode(lrc).decode('utf-8')
return LyricParser._parse_lrc(lrc)
except:
pass
return []
# 酷狗
@classmethod
def _search_kugou(cls, title: str, artist: str) -> List[LyricLine]:
try:
results = cls._search_kugou_results(title, artist)
if results:
return cls._get_kugou_lyric(results[0].lyric_id)
except:
pass
return []
@classmethod
def _search_kugou_results(cls, title: str, artist: str) -> List[LyricSearchResult]:
results = []
try:
url = "https://songsearch.kugou.com/song_search_v2?" + urllib.parse.urlencode({
'keyword': f"{title} {artist}".strip(), 'page': '1', 'pagesize': '5', 'platform': 'WebFilter'
})
req = urllib.request.Request(url, headers={
'User-Agent': 'Mozilla/5.0', 'Referer': 'https://www.kugou.com/'
})
with urllib.request.urlopen(req, timeout=8) as resp:
data = json.loads(resp.read().decode('utf-8'))
if data.get('status') != 1: return []
for song in data.get('data', {}).get('lists', []):
results.append(LyricSearchResult(
title=song.get('SongName', ''),
artist=song.get('SingerName', ''),
source="酷狗",
lyric_id=song.get('FileHash', '')
))
except:
pass
return results
@classmethod
def _get_kugou_lyric(cls, file_hash: str) -> List[LyricLine]:
try:
url = f"https://www.kugou.com/yy/index.php?r=play/getdata&hash={file_hash}"
req = urllib.request.Request(url, headers={
'User-Agent': 'Mozilla/5.0', 'Referer': 'https://www.kugou.com/'
})
with urllib.request.urlopen(req, timeout=8) as resp:
data = json.loads(resp.read().decode('utf-8'))
if data.get('status') != 1: return []
lyrics = data.get('data', {}).get('lyrics', '')
return LyricParser._parse_lrc(lyrics) if lyrics else []
except:
return []
# 酷我
@classmethod
def _search_kuwo(cls, title: str, artist: str) -> List[LyricLine]:
try:
url = "https://www.kuwo.cn/api/www/search/searchMusicBykeyWord?" + urllib.parse.urlencode({
'key': f"{title} {artist}".strip(), 'pn': '1', 'rn': '1'
})
req = urllib.request.Request(url, headers={
'User-Agent': 'Mozilla/5.0', 'Referer': 'https://www.kuwo.cn/search/list'
})
with urllib.request.urlopen(req, timeout=8) as resp:
data = json.loads(resp.read().decode('utf-8'))
if data.get('code') != 200: return []
songs = data.get('data', {}).get('list', [])
if not songs: return []
return cls._get_kuwo_lyric(str(songs[0].get('rid', '')))
except:
return []
@classmethod
def _get_kuwo_lyric(cls, song_id: str) -> List[LyricLine]:
try:
url = f"https://www.kuwo.cn/api/v1/www/music/playInfo?mid={song_id}&type=lyric"
req = urllib.request.Request(url, headers={
'User-Agent': 'Mozilla/5.0', 'Referer': 'https://www.kuwo.cn/'
})
with urllib.request.urlopen(req, timeout=8) as resp:
data = json.loads(resp.read().decode('utf-8'))
if data.get('code') != 200: return []
lrc_url = data.get('data', {}).get('lyric', '')
if lrc_url:
req2 = urllib.request.Request(lrc_url, headers={'User-Agent': 'Mozilla/5.0'})
with urllib.request.urlopen(req2, timeout=8) as resp2:
return LyricParser._parse_lrc(resp2.read().decode('utf-8', errors='ignore'))
except:
pass
return []
# 咪咕
@classmethod
def _search_migu(cls, title: str, artist: str) -> List[LyricLine]:
try:
url = "https://m.music.migu.cn/migu/remoting/scr_search_tag?" + urllib.parse.urlencode({
'keyword': f"{title} {artist}".strip(), 'type': '2', 'pg': '1', 'pz': '1'
})
req = urllib.request.Request(url, headers={
'User-Agent': 'Mozilla/5.0 (Linux; Android)', 'Referer': 'https://m.music.migu.cn/'
})
with urllib.request.urlopen(req, timeout=8) as resp:
data = json.loads(resp.read().decode('utf-8'))
if data.get('success') != True: return []
songs = data.get('musics', [])
if not songs: return []
return cls._get_migu_lyric(songs[0].get('copyrightId', ''))
except:
return []
@classmethod
def _get_migu_lyric(cls, copyright_id: str) -> List[LyricLine]:
try:
url = f"https://music.migu.cn/v3/api/listen-api/getLyric?copyrightId={copyright_id}"
req = urllib.request.Request(url, headers={
'User-Agent': 'Mozilla/5.0', 'Referer': 'https://music.migu.cn/'
})
with urllib.request.urlopen(req, timeout=8) as resp:
data = json.loads(resp.read().decode('utf-8'))
if data.get('returnCode') != '000000': return []
lrc = data.get('lyric', '')
return LyricParser._parse_lrc(lrc) if lrc else []
except:
return []
# ===== 窗口磁吸管理器 =====
class WindowSnapManager:
SNAP_THRESHOLD = 25
def __init__(self, main_window):
self.main = main_window
self.windows = {}
self.snap_relations = {}
self.snapping = False
self.dragging = False
self.main.bind('<Configure>', self._on_main_move, '+')
self.main.bind('<ButtonPress-1>', lambda e: setattr(self, 'dragging', True), '+')
self.main.bind('<ButtonRelease-1>', lambda e: setattr(self, 'dragging', False), '+')
def register(self, name: str, window: tk.Toplevel):
self.windows[name] = window
window.bind('<Configure>', lambda e: self._on_window_move(name), '+')
def _on_main_move(self, event):
if self.snapping or not self.dragging:
return
main_x = self.main.winfo_x()
main_y = self.main.winfo_y()
main_w = self.main.winfo_width()
main_h = self.main.winfo_height()
for name, (target, edge) in list(self.snap_relations.items()):
if target == 'main':
window = self.windows.get(name)
if window and window.winfo_viewable():
self.snapping = True
try:
wx, wy = window.winfo_x(), window.winfo_y()
if edge == 'right':
window.geometry(f"+{main_x + main_w}+{wy}")
elif edge == 'bottom':
window.geometry(f"+{wx}+{main_y + main_h}")
finally:
self.snapping = False
def _on_window_move(self, name: str):
if self.snapping or name not in self.windows:
return
moved = self.windows[name]
new_x, new_y = moved.winfo_x(), moved.winfo_y()
new_w, new_h = moved.winfo_width(), moved.winfo_height()
main_x, main_y = self.main.winfo_x(), self.main.winfo_y()
main_w, main_h = self.main.winfo_width(), self.main.winfo_height()
snap_x, snap_y, snap_edge = None, None, None
if abs(new_x - (main_x + main_w)) < self.SNAP_THRESHOLD:
snap_x, snap_edge = main_x + main_w, 'right'
elif abs((new_x + new_w) - main_x) < self.SNAP_THRESHOLD:
snap_x, snap_edge = main_x - new_w, 'left'
if abs(new_y - (main_y + main_h)) < self.SNAP_THRESHOLD:
snap_y, snap_edge = main_y + main_h, snap_edge or 'bottom'
elif abs((new_y + new_h) - main_y) < self.SNAP_THRESHOLD:
snap_y, snap_edge = main_y - new_h, snap_edge or 'top'
if snap_x is not None or snap_y is not None:
self.snapping = True
try:
moved.geometry(f"+{snap_x or new_x}+{snap_y or new_y}")
self.snap_relations[name] = ('main', snap_edge)
finally:
self.snapping = False
else:
self.snap_relations.pop(name, None)
def snap_all_to_main(self):
main_x, main_y = self.main.winfo_x(), self.main.winfo_y()
main_w, main_h = self.main.winfo_width(), self.main.winfo_height()
offset_y = 0
for name, window in self.windows.items():
if window.winfo_viewable():
self.snapping = True
try:
window.geometry(f"+{main_x + main_w}+{main_y + offset_y}")
self.snap_relations[name] = ('main', 'right')
offset_y += window.winfo_height()
finally:
self.snapping = False
# ===== 频谱颜色管理器 =====
class SpectrumColorManager:
"""频谱颜色渐变管理器"""
# 千千静听经典配色方案
PRESETS = {
'classic': ['#0066ff', '#00ccff', '#00ffcc', '#00ff66', '#66ff00'],
'purple': ['#6600ff', '#9900ff', '#cc00ff', '#ff00ff', '#ff66ff'],
'fire': ['#ff3300', '#ff6600', '#ff9900', '#ffcc00', '#ffff00'],
'ocean': ['#003366', '#0066cc', '#0099ff', '#00ccff', '#66ffff'],
'rainbow': ['#ff0000', '#ff9900', '#ffff00', '#00ff00', '#0099ff'],
}
def __init__(self, preset='classic'):
self.preset = preset
self.phase = 0.0
self.colors = self.PRESETS.get(preset, self.PRESETS['classic'])
def set_preset(self, preset: str):
if preset in self.PRESETS:
self.preset = preset
self.colors = self.PRESETS[preset]
def cycle_preset(self) -> str:
presets = list(self.PRESETS.keys())
idx = (presets.index(self.preset) + 1) % len(presets)
self.set_preset(presets[idx])
return self.preset
def get_bar_color(self, bar_index: int, total_bars: int, height_ratio: float) -> str:
"""获取频谱条颜色 - 基于位置和高度渐变"""
# 基于条位置的基础颜色插值
pos_ratio = bar_index / max(1, total_bars - 1)
color_idx = int(pos_ratio * (len(self.colors) - 1))
color_idx = min(color_idx, len(self.colors) - 1)
# 从基础颜色提取RGB
base_color = self.colors[color_idx]
r, g, b = self._hex_to_rgb(base_color)
# 基于高度调整亮度
brightness = 0.3 + height_ratio * 0.7
r = int(r * brightness)
g = int(g * brightness)
b = int(b * brightness)
# 添加相位偏移产生动态效果
phase_shift = math.sin(self.phase + bar_index * 0.3) * 30
r = max(0, min(255, r + int(phase_shift)))
g = max(0, min(255, g + int(phase_shift * 0.5)))
b = max(0, min(255, b - int(phase_shift * 0.3)))
return f'#{r:02x}{g:02x}{b:02x}'
def get_glow_color(self, bar_index: int) -> str:
"""获取发光颜色"""
color_idx = bar_index % len(self.colors)
return self.colors[color_idx]
def advance_phase(self, delta: float = 0.1):
"""推进相位动画"""
self.phase = (self.phase + delta) % (2 * math.pi)
@staticmethod
def _hex_to_rgb(hex_color: str) -> Tuple[int, int, int]:
"""十六进制转RGB"""
hex_color = hex_color.lstrip('#')
return tuple(int(hex_color[i:i+2], 16) for i in (0, 2, 4))
# ===== 频谱动画引擎 =====
class SpectrumEngine:
"""频谱动画引擎 - 模拟真实音频律动"""
def __init__(self, num_bars: int = 24):
self.num_bars = num_bars
self.values = [0.0] * num_bars
self.target_values = [0.0] * num_bars
self.velocities = [0.0] * num_bars
# 频谱物理参数
self.gravity = 0.15
self.dampening = 0.85
self.attack_speed = 0.4
self.decay_speed = 0.08
# 节拍检测
self.beat_phase = 0.0
self.beat_intensity = 0.0
self.last_beat_time = time.time()
# 频率分布模拟(低频到高频)
self.freq_weights = self._generate_freq_weights()
def _generate_freq_weights(self) -> List[float]:
"""生成频率权重分布(模拟真实音频频谱)"""
weights = []
for i in range(self.num_bars):
# 低频能量大,高频能量小,呈指数衰减
weight = 1.0 - (i / self.num_bars) ** 0.7
weights.append(weight * 0.5 + 0.5)
return weights
def update(self, is_playing: bool, position: float, duration: float):
"""更新频谱数据"""
if not is_playing:
# 暂停时衰减
for i in range(self.num_bars):
self.target_values[i] *= 0.9
else:
# 播放时生成频谱
self._generate_spectrum(position, duration)
# 应用物理动画
self._apply_physics()
# 更新节拍
self._update_beat(is_playing, position)
def _generate_spectrum(self, position: float, duration: float):
"""生成频谱数据(基于时间的模拟律动)"""
progress = position / max(1, duration)
for i in range(self.num_bars):
# 基于时间的周期性变化
time_factor = math.sin(position * (2 + i * 0.3) + i * 0.5)
# 低频带变化慢,高频带变化快
freq_factor = math.sin(position * (4 + i * 0.8) + i)
# 随机性模拟真实音频
noise = random.random() * 0.3 - 0.15
# 综合计算目标值
base = 0.2 + self.freq_weights[i] * 0.5
variation = time_factor * 0.25 + freq_factor * 0.15 + noise
# 添加节拍脉冲
beat_pulse = self.beat_intensity * (1.0 - i / self.num_bars) * 0.4
self.target_values[i] = max(0, min(1, base + variation + beat_pulse))
def _apply_physics(self):
"""应用物理动画"""
for i in range(self.num_bars):
# 计算差值
diff = self.target_values[i] - self.values[i]
# 弹簧力
spring_force = diff * self.attack_speed
# 应用速度和重力
self.velocities[i] += spring_force
self.velocities[i] -= self.gravity * 0.1
self.velocities[i] *= self.dampening
# 更新值
self.values[i] += self.velocities[i]
self.values[i] = max(0, min(1, self.values[i]))
def _update_beat(self, is_playing: bool, position: float):
"""更新节拍检测"""
if is_playing:
# 模拟 BPM 120 的节拍(每秒2拍)
beat_interval = 0.5
time_since_beat = (position % beat_interval) / beat_interval
# 节拍峰值
if time_since_beat < 0.1:
self.beat_intensity = 1.0 - time_since_beat * 10
else:
self.beat_intensity *= 0.9
else:
self.beat_intensity *= 0.8
def get_values(self) -> List[float]:
"""获取当前频谱值"""
return self.values.copy()
def get_beat_intensity(self) -> float:
"""获取节拍强度"""
return self.beat_intensity
# ===== 音频引擎 =====
class AudioEngine:
def __init__(self):
self._sample_rate = 44100
self._channel_mode = MultiSourceLyricSearch.CHANNEL_STEREO
self._init_mixer()
self._current_file = None
self._paused = False
self._volume = 0.7
self._pause_pos = 0
self._playing = False
self._position_start_time = 0.0 # track real playback start (秒)
self._position_start_pos = 0 # track start offset (毫秒)
self._position_paused_at = 0.0 # how many seconds were accumulated before this pause
def _init_mixer(self):
try:
pygame.mixer.quit()
pygame.mixer.init(frequency=self._sample_rate, size=-16, channels=2, buffer=512)
# 设置音乐结束事件
pygame.mixer.music.set_endevent(pygame.USEREVENT + 1)
except Exception as e:
print(f"初始化混音器失败: {e}")
def set_sample_rate(self, rate: int):
if rate != self._sample_rate:
self._sample_rate = rate
was_playing = self.is_playing()
was_paused = self.is_paused()
pos = self.get_position()
self._init_mixer()
if self._current_file:
self.load(self._current_file)
self.set_position(pos)
if was_playing: self.play()
if was_paused: self.pause()
def set_channel_mode(self, mode: str):
self._channel_mode = mode
self._apply_channel_mode()
def _apply_channel_mode(self):
vol = self._volume
pygame.mixer.music.set_volume(vol)
def get_channel_mode(self) -> str:
return self._channel_mode
def get_audio_info(self) -> dict:
init_info = pygame.mixer.get_init()
return {
'frequency': init_info[0] if init_info else self._sample_rate,
'channel_mode': self._channel_mode
}
def load(self, path: str) -> bool:
try:
self.stop()
pygame.mixer.music.load(path)
self._current_file = path
self._pause_pos = 0
self._playing = False
return True
except Exception as e:
print(f"加载失败: {e}")
return False
def play(self):
if self._current_file:
if self._paused:
# Resume: 保持 _position_paused_at 为暂停时的累计时间
# get_position() 会返回: _position_paused_at + (now - resume_time)
self._position_start_time = pygame.time.get_ticks() / 1000.0
self._position_start_pos = 0 # 重置,因为已经累计到 _position_paused_at 里了
pygame.mixer.music.unpause()
else:
pygame.mixer.music.play()
# Fresh start
self._position_start_time = pygame.time.get_ticks() / 1000.0
self._position_start_pos = 0
self._position_paused_at = 0.0
self._paused = False
self._playing = True
self._apply_channel_mode()
def pause(self):
if self._playing and not self._paused:
pygame.mixer.music.pause()
# Capture accumulated time so far
elapsed = (pygame.time.get_ticks() / 1000.0 - self._position_start_time) * 1000 + self._position_start_pos
self._position_paused_at = elapsed / 1000.0
self._pause_pos = self._position_paused_at
self._paused = True
def stop(self):
pygame.mixer.music.stop()
self._paused = False
self._playing = False
self._pause_pos = 0
self._position_paused_at = 0.0
self._position_start_time = 0.0
self._position_start_pos = 0
def set_volume(self, vol: float):
self._volume = max(0, min(1, vol))
self._apply_channel_mode()
def get_volume(self) -> float:
return self._volume
def get_position(self) -> float:
if self._paused:
return self._pause_pos
if not pygame.mixer.get_init():
return 0.0
try:
busy = pygame.mixer.music.get_busy()
if not busy:
# Music finished naturally
return self._pause_pos
# 使用 pygame 内部计时器(毫秒)
pos_ms = pygame.mixer.music.get_pos()
if pos_ms < 0:
return self._pause_pos
# 返回累计暂停位置 + 当前播放位置
return self._position_paused_at + pos_ms / 1000.0
except Exception:
return 0.0
def set_position(self, pos: float):
if self._current_file:
was_playing = self._playing and not self._paused
pygame.mixer.music.stop()
pygame.mixer.music.play(start=pos)
self._paused = False
self._playing = True
self._position_paused_at = pos
self._position_start_time = pygame.time.get_ticks() / 1000.0
self._position_start_pos = 0
try:
pygame.event.clear(pygame.USEREVENT + 1)
except pygame.error:
pass # event subsystem not available
if not was_playing:
pygame.mixer.music.pause()
self._paused = True
self._pause_pos = pos
def is_playing(self) -> bool:
return self._playing and not self._paused
def is_paused(self) -> bool:
return self._paused
def is_stopped(self) -> bool:
"""音乐是否已停止(非暂停、非播放)"""
return not self._playing
# ===== 播放列表 =====
class PlaylistManager:
MODE_ORDER = "order"
MODE_RANDOM = "random"
MODE_SINGLE = "single"
MODE_LOOP = "loop"
MODES = [MODE_ORDER, MODE_RANDOM, MODE_SINGLE, MODE_LOOP]
def __init__(self):
self.songs: List[Song] = []
self.current_index = -1
self._mode_index = 0
self._random_history = []
@property
def mode(self) -> str:
return self.MODES[self._mode_index]
def cycle_mode(self) -> str:
self._mode_index = (self._mode_index + 1) % len(self.MODES)
if self.MODES[self._mode_index] == self.MODE_RANDOM:
self._random_history = []
return self.mode
def add(self, song: Song):
self.songs.append(song)
def clear(self):
self.songs.clear()
self.current_index = -1
self._random_history = []
def next(self) -> Optional[Song]:
if not self.songs: return None
if self.mode == self.MODE_SINGLE:
return self.songs[self.current_index] if 0 <= self.current_index < len(self.songs) else None
elif self.mode == self.MODE_RANDOM:
available = [i for i in range(len(self.songs)) if i not in self._random_history]
if not available:
self._random_history = []
available = list(range(len(self.songs)))
self.current_index = random.choice(available)
self._random_history.append(self.current_index)
elif self.mode == self.MODE_LOOP:
self.current_index = (self.current_index + 1) % len(self.songs)
else:
self.current_index += 1
if self.current_index >= len(self.songs): return None
return self.songs[self.current_index] if 0 <= self.current_index < len(self.songs) else None
def prev(self) -> Optional[Song]:
if not self.songs: return None
if self.mode == self.MODE_SINGLE:
return self.songs[self.current_index] if 0 <= self.current_index < len(self.songs) else None
elif self.mode == self.MODE_RANDOM:
if len(self._random_history) > 1:
self._random_history.pop()
self.current_index = self._random_history[-1]
else:
self.current_index = random.randint(0, len(self.songs) - 1)
else:
self.current_index = (self.current_index - 1) % len(self.songs)
return self.songs[self.current_index] if 0 <= self.current_index < len(self.songs) else None
def get_current(self) -> Optional[Song]:
return self.songs[self.current_index] if 0 <= self.current_index < len(self.songs) else None
# ===== 主应用 =====
class DiDiPlayerApp:
APP_NAME = "迪52o"
VERSION = "v14.0"
COLORS = {
'bg_dark': '#1a0033', 'bg_mid': '#2d004d', 'bg_light': '#4a0080',
'accent': '#00cc66', 'accent_dark': '#009944',
'text': '#ffffff', 'text_dim': '#b388ff', 'text_gold': '#ffd700',
'progress_bg': '#333366', 'progress_fill': '#00cc66',
'spectrum': '#4488ff', 'mode_active': '#ff6600',
}
# 播放列表保存路径
PLAYLIST_DIR = Path(os.environ.get('LOCALAPPDATA', '.')) / 'DiDiPlayer'
PLAYLIST_FILE = PLAYLIST_DIR / 'playlist.json'
CONFIG_FILE = PLAYLIST_DIR / 'config.json'
MODE_INFO = {
PlaylistManager.MODE_ORDER: ("➡️", "顺序"),
PlaylistManager.MODE_RANDOM: ("🔀", "随机"),
PlaylistManager.MODE_SINGLE: ("🔂", "单曲"),
PlaylistManager.MODE_LOOP: ("🔁", "循环"),
}
CHANNEL_INFO = {
MultiSourceLyricSearch.CHANNEL_STEREO: ("🔊", "立体声"),
MultiSourceLyricSearch.CHANNEL_LEFT: ("◀", "左声道"),
MultiSourceLyricSearch.CHANNEL_RIGHT: ("▶", "右声道"),
}
def __init__(self):
# 状态文件
self.STATE_FILE = self.PLAYLIST_DIR / "state.json"
# 创建配置目录
self.PLAYLIST_DIR.mkdir(parents=True, exist_ok=True)
# 加载配置
self.config = self._load_config()
self.root = tk.Tk()
self.root.title(f"{self.APP_NAME} {self.VERSION}")
self.root.configure(bg=self.COLORS['bg_dark'])
self.root.geometry("480x420")
# 背景图片
self.bg_image = None
self.bg_label = None
self._apply_background()
# 透明度(Windows only)
try:
self.root.attributes('-alpha', self.config.get('opacity', 1.0))
except:
pass
# 退出时保存状态
self.root.protocol("WM_DELETE_WINDOW", self._quit_and_save)
# 进度条拖拽状态
self._dragging = False
self.audio = AudioEngine()
self.playlist = PlaylistManager()
self.snap_manager = WindowSnapManager(self.root)
self.spectrum_engine = SpectrumEngine(num_bars=24)
self.color_manager = SpectrumColorManager(preset='classic')
self._lyrics: List[LyricLine] = []
self._lyric_index = -1
self._lyric_search_results: List[LyricSearchResult] = []
self._smooth_scroll_offset = 0.0
self._lyrics_loading = False # 歌词加载中标志
self._build_ui()
self._restore_state() # 恢复上次退出状态
self._update_loop()
def _build_ui(self):
main = tk.Frame(self.root, bg=self.COLORS['bg_dark'])
main.pack(fill=tk.BOTH, expand=True, padx=10, pady=10)
# 歌曲信息
self.title_label = tk.Label(main, text="欢迎使用 迪52o", font=("Microsoft YaHei", 14, "bold"),
fg=self.COLORS['text_gold'], bg=self.COLORS['bg_dark'])
self.title_label.pack(fill=tk.X)
self.time_label = tk.Label(main, text="00:00 / 00:00", font=("Consolas", 11),
fg=self.COLORS['text_dim'], bg=self.COLORS['bg_dark'])
self.time_label.pack(fill=tk.X)
# 频谱画布
self.spectrum_canvas = tk.Canvas(main, height=60, bg=self.COLORS['bg_mid'], highlightthickness=0)
self.spectrum_canvas.pack(fill=tk.X, pady=5)
# 频谱配色按钮
spectrum_ctrl = tk.Frame(main, bg=self.COLORS['bg_dark'])
spectrum_ctrl.pack(fill=tk.X)
tk.Label(spectrum_ctrl, text="配色:", bg=self.COLORS['bg_dark'], fg=self.COLORS['text_dim']).pack(side=tk.LEFT)
self.color_btn = tk.Button(spectrum_ctrl, text="🎨", command=self._cycle_color,
bg=self.COLORS['bg_light'], fg=self.COLORS['text'], bd=0, width=3, cursor='hand2')
self.color_btn.pack(side=tk.LEFT, padx=5)
# 进度条(固定宽度,确保 winfo_reqwidth 有返回值)
self.progress_canvas = tk.Canvas(main, height=15, width=460, bg=self.COLORS['progress_bg'], highlightthickness=0)
self.progress_canvas.pack(fill=tk.X)
# 拖拽进度条支持
self.progress_canvas.bind('<Button-1>', self._seek_start)
self.progress_canvas.bind('<B1-Motion>', self._seek_drag)
self.progress_canvas.bind('<ButtonRelease-1>', self._seek_end)
# 控制按钮
ctrl = tk.Frame(main, bg=self.COLORS['bg_dark'])
ctrl.pack(fill=tk.X, pady=10)
self.btn_prev = tk.Button(ctrl, text="⏮", command=self._prev, **self._btn_style())
self.btn_prev.pack(side=tk.LEFT, padx=2)
self.btn_play = tk.Button(ctrl, text="▶", command=self._toggle, bg=self.COLORS['accent'], width=4)
self.btn_play.pack(side=tk.LEFT, padx=2)
self.btn_next = tk.Button(ctrl, text="⏭", command=self._next, **self._btn_style())
self.btn_next.pack(side=tk.LEFT, padx=2)
self.btn_mode = tk.Button(ctrl, text="➡️", command=self._cycle_mode, **self._btn_style())
self.btn_mode.pack(side=tk.LEFT, padx=10)
self.btn_channel = tk.Button(ctrl, text="🔊", command=self._cycle_channel, **self._btn_style())
self.btn_channel.pack(side=tk.LEFT, padx=2)
self.volume_scale = tk.Scale(ctrl, from_=0, to=100, orient=tk.HORIZONTAL, length=80,
command=self._volume, bg=self.COLORS['bg_dark'], fg=self.COLORS['text'],
troughcolor=self.COLORS['progress_bg'], highlightthickness=0)
self.volume_scale.set(70)
self.volume_scale.pack(side=tk.LEFT, padx=5)
self.btn_lyric = tk.Button(ctrl, text="🎤", command=self._toggle_lyric, **self._btn_style())
self.btn_lyric.pack(side=tk.RIGHT, padx=2)
self.btn_list = tk.Button(ctrl, text="📋", command=self._toggle_list, **self._btn_style())
self.btn_list.pack(side=tk.RIGHT, padx=2)
self.btn_open = tk.Button(ctrl, text="📂", command=self._open, **self._btn_style())
self.btn_open.pack(side=tk.RIGHT, padx=2)
# 状态栏
self.status_label = tk.Label(main, text="状态: 就绪 | 立体声 | 44.1kHz",
font=("Microsoft YaHei", 9), fg=self.COLORS['text_dim'], bg=self.COLORS['bg_dark'])
self.status_label.pack(side=tk.BOTTOM, fill=tk.X)
self._build_list_window()
self._build_lyric_window()
self._build_search_result_window()
def _btn_style(self):
return {'bg': self.COLORS['bg_light'], 'fg': self.COLORS['text'],
'bd': 0, 'width': 3, 'height': 1, 'cursor': 'hand2'}
def _build_list_window(self):
self.list_win = tk.Toplevel(self.root)
self.list_win.title("播放列表")
self.list_win.geometry("420x280+500+0")
self.list_win.configure(bg=self.COLORS['bg_dark'])
self.list_win.withdraw()
self.snap_manager.register('list', self.list_win)
# 工具栏
toolbar = tk.Frame(self.list_win, bg=self.COLORS['bg_mid'])
toolbar.pack(fill=tk.X)
tk.Button(toolbar, text="➕", command=self._open, **self._btn_style()).pack(side=tk.LEFT, padx=2)
tk.Button(toolbar, text="➖", command=self._remove, **self._btn_style()).pack(side=tk.LEFT, padx=2)
tk.Button(toolbar, text="🗑", command=self._clear, **self._btn_style()).pack(side=tk.LEFT, padx=2)
tk.Button(toolbar, text="💾", command=self._save_playlist, **self._btn_style()).pack(side=tk.LEFT, padx=2)
tk.Button(toolbar, text="📂", command=self._load_playlist, **self._btn_style()).pack(side=tk.LEFT, padx=2)
tk.Label(toolbar, text="采样率:", bg=self.COLORS['bg_mid'], fg=self.COLORS['text_dim']).pack(side=tk.LEFT, padx=(10, 2))
self.sample_var = tk.StringVar(value="44.1kHz")
ttk.Combobox(toolbar, textvariable=self.sample_var, values=["44.1kHz", "48kHz", "96kHz"],
width=7, state='readonly').pack(side=tk.LEFT)
self.sample_var.trace('w', self._sample_change)
self.listbox = tk.Listbox(self.list_win, bg=self.COLORS['bg_mid'], fg=self.COLORS['text'],
selectbackground=self.COLORS['accent'], font=("Microsoft YaHei", 10), bd=0, highlightthickness=0)
self.listbox.pack(fill=tk.BOTH, expand=True, padx=5, pady=5)
self.listbox.bind('<Double-Button-1>', self._play_selected)
def _build_lyric_window(self):
self.lyric_win = tk.Toplevel(self.root)
self.lyric_win.title("歌词")
self.lyric_win.geometry("420x260+500+300")
self.lyric_win.configure(bg=self.COLORS['bg_dark'])
self.lyric_win.withdraw()
self.snap_manager.register('lyric', self.lyric_win)
# 搜索框
search = tk.Frame(self.lyric_win, bg=self.COLORS['bg_mid'])
search.pack(fill=tk.X, padx=5, pady=5)
tk.Label(search, text="搜索:", bg=self.COLORS['bg_mid'], fg=self.COLORS['text_dim']).pack(side=tk.LEFT, padx=5)
self.search_entry = tk.Entry(search, bg=self.COLORS['bg_light'], fg=self.COLORS['text'],
font=("Microsoft YaHei", 10), bd=0)
self.search_entry.pack(side=tk.LEFT, fill=tk.X, expand=True, padx=2)
self.search_entry.bind('<Return>', lambda e: self._search_lyric())
tk.Button(search, text="🔍", command=self._search_lyric, **self._btn_style()).pack(side=tk.LEFT, padx=2)
tk.Button(search, text="🔗", command=self._auto_lyric, **self._btn_style()).pack(side=tk.LEFT, padx=2)
# 歌词显示
self.lyric_text = tk.Text(self.lyric_win, bg=self.COLORS['bg_mid'], fg=self.COLORS['text'],
font=("Microsoft YaHei", 12), bd=0, highlightthickness=0, wrap=tk.WORD)
self.lyric_text.pack(fill=tk.BOTH, expand=True, padx=5, pady=5)
self.lyric_text.config(state=tk.DISABLED)
self.lyric_source = tk.Label(self.lyric_win, text="", font=("Microsoft YaHei", 8),
fg=self.COLORS['text_dim'], bg=self.COLORS['bg_dark'])
self.lyric_source.pack(side=tk.BOTTOM)
def _build_search_result_window(self):
"""歌词搜索结果窗口"""
self.search_win = tk.Toplevel(self.root)
self.search_win.title("选择歌词")
self.search_win.geometry("500x350+550+100")
self.search_win.configure(bg=self.COLORS['bg_dark'])
self.search_win.withdraw()
tk.Label(self.search_win, text="双击选择最匹配的歌词:",
bg=self.COLORS['bg_dark'], fg=self.COLORS['text_dim'],
font=("Microsoft YaHei", 10)).pack(pady=5)
# 结果列表
columns = ('title', 'artist', 'source')
self.result_tree = ttk.Treeview(self.search_win, columns=columns, show='headings', height=12)
self.result_tree.heading('title', text='歌曲名')
self.result_tree.heading('artist', text='歌手')
self.result_tree.heading('source', text='来源')
self.result_tree.column('title', width=200)
self.result_tree.column('artist', width=150)
self.result_tree.column('source', width=100)
self.result_tree.pack(fill=tk.BOTH, expand=True, padx=10, pady=5)
self.result_tree.bind('<Double-Button-1>', self._select_lyric_result)
# 配置样式
style = ttk.Style()
style.theme_use('clam')
style.configure("Treeview", background=self.COLORS['bg_mid'],
foreground=self.COLORS['text'], fieldbackground=self.COLORS['bg_mid'])
style.configure("Treeview.Heading", background=self.COLORS['bg_light'],
foreground=self.COLORS['text'])
style.map("Treeview", background=[('selected', self.COLORS['accent'])])
def _cycle_mode(self):
mode = self.playlist.cycle_mode()
self.btn_mode.config(text=self.MODE_INFO[mode][0])
self.status_label.config(text=self._status_text())
def _cycle_channel(self):
modes = [MultiSourceLyricSearch.CHANNEL_STEREO,
MultiSourceLyricSearch.CHANNEL_LEFT,
MultiSourceLyricSearch.CHANNEL_RIGHT]
current = self.audio.get_channel_mode()
next_mode = modes[(modes.index(current) + 1) % len(modes)]
self.audio.set_channel_mode(next_mode)
self.btn_channel.config(text=self.CHANNEL_INFO[next_mode][0])
self.status_label.config(text=self._status_text())
def _cycle_color(self):
preset = self.color_manager.cycle_preset()
self.color_btn.config(text=f"🎨")
def _sample_change(self, *args):
rate_map = {"44.1kHz": 44100, "48kHz": 48000, "96kHz": 96000}
self.audio.set_sample_rate(rate_map.get(self.sample_var.get(), 44100))
self.status_label.config(text=self._status_text())
def _status_text(self) -> str:
info = self.audio.get_audio_info()
sr = info['frequency']
sr_text = f"{sr/1000:.1f}kHz" if sr < 1000 else f"{sr//1000}kHz"
ch_text = self.CHANNEL_INFO.get(info['channel_mode'], ("", "立体声"))[1]
mode_text = self.MODE_INFO[self.playlist.mode][1]
color_preset = self.color_manager.preset
return f"状态: {mode_text} | {ch_text} | {sr_text} | 配色:{color_preset}"
def _toggle(self):
if self.audio.is_playing():
self.audio.pause()
self.btn_play.config(text="▶")
elif self.audio.is_paused():
self.audio.play()
self.btn_play.config(text="⏸")
elif self.playlist.get_current():
self._play(self.playlist.get_current())
def _play(self, song: Song):
if self.audio.load(song.path):
self.audio.play()
self.btn_play.config(text="⏸")
self.title_label.config(text=f"{song.title} - {song.artist}" if song.artist else song.title)
# 确保时长正确
if song.duration <= 0:
song._load_metadata()
self._load_lyric(song)
def _prev(self):
song = self.playlist.prev()
if song: self._play(song)
def _next(self):
song = self.playlist.next()
if song: self._play(song)
else:
self.audio.stop()
self.btn_play.config(text="▶")
def _open(self):
files = filedialog.askopenfilenames(filetypes=[("音乐", "*.mp3 *.wav *.ogg *.flac *.m4a")])
if files:
for f in files:
song = Song(f)
self.playlist.add(song)
self.listbox.insert(tk.END, f"{song.title} - {song.artist}" if song.artist else song.title)
def _remove(self):
if self.listbox.curselection():
idx = self.listbox.curselection()[0]
self.listbox.delete(idx)
del self.playlist.songs[idx]
def _clear(self):
self.playlist.clear()
self.listbox.delete(0, tk.END)
def _play_selected(self, event):
if self.listbox.curselection():
self.playlist.current_index = self.listbox.curselection()[0]
self._play(self.playlist.get_current())
def _toggle_list(self):
if self.list_win.winfo_viewable():
self.list_win.withdraw()
else:
self.list_win.deiconify()
def _toggle_lyric(self):
if self.lyric_win.winfo_viewable():
self.lyric_win.withdraw()
else:
self.lyric_win.deiconify()
def _seek(self, x):
song = self.playlist.get_current()
if song and song.duration > 0:
# 限制 x 在进度条范围内
w = self.progress_canvas.winfo_width()
if w <= 0: return
x = max(0, min(x, w))
pos = (x / w) * song.duration
self.audio.set_position(max(0, pos))
def _volume(self, val):
self.audio.set_volume(float(val) / 100)
def _load_lyric(self, song: Song):
self._lyrics = []
self._lyric_index = -1
self._lyrics_loading = True # 标记正在加载
# 尝试本地歌词
found_local = False
for ext in ['.lrc', '.krc', '.qrc', '.txt']:
lrc_path = Path(song.path).with_suffix(ext)
if lrc_path.exists():
try:
with open(lrc_path, 'rb') as f:
self._lyrics = LyricParser.parse(f.read(), ext[1:])
if self._lyrics:
self.lyric_source.config(text=f"来源: 本地{ext}")
self._lyrics_loading = False
found_local = True
break
except:
continue
# 更新显示
self._update_lyric_display()
# 填充搜索框
self.search_entry.delete(0, tk.END)
self.search_entry.insert(0, f"{song.title} {song.artist}".strip())
# 如果没有本地歌词,自动在线搜索
if not found_local and song.title:
threading.Thread(target=self._auto_search_lyric, args=(song,), daemon=True).start()
def _update_lyric_display(self):
"""更新歌词显示"""
if not self._lyrics:
return
self._lyrics_loading = False
self.lyric_text.config(state=tk.NORMAL)
self.lyric_text.delete(1.0, tk.END)
for line in self._lyrics:
self.lyric_text.insert(tk.END, line.text + '\n')
self.lyric_text.config(state=tk.DISABLED)
def _search_lyric(self):
"""搜索歌词并显示结果"""
query = self.search_entry.get().strip()
if not query:
return
threading.Thread(target=self._do_search_and_show, args=(query,), daemon=True).start()
def _auto_lyric(self):
song = self.playlist.get_current()
if song:
self._do_search_and_show(f"{song.title} {song.artist}".strip())
def _do_search_and_show(self, query: str):
"""搜索并显示结果列表"""
try:
results = MultiSourceLyricSearch.search_and_get_results(query, "")
if results:
self._lyric_search_results = results
# 在主线程更新UI
self.root.after(0, self._show_search_results)
else:
self.root.after(0, lambda: messagebox.showinfo("提示", "未找到匹配的歌词"))
except Exception as e:
print(f"歌词搜索失败: {e}")
def _show_search_results(self):
"""显示搜索结果窗口"""
# 清空旧结果
for item in self.result_tree.get_children():
self.result_tree.delete(item)
# 添加新结果
for i, result in enumerate(self._lyric_search_results):
self.result_tree.insert('', tk.END, iid=str(i), values=(
result.title,
result.artist,
result.source
))
# 显示窗口
self.search_win.deiconify()
self.search_win.lift()
def _select_lyric_result(self, event):
"""选择搜索结果"""
selection = self.result_tree.selection()
if not selection:
return
idx = int(selection[0])
if 0 <= idx < len(self._lyric_search_results):
result = self._lyric_search_results[idx]
threading.Thread(target=self._load_selected_lyric, args=(result,), daemon=True).start()
self.search_win.withdraw()
def _load_selected_lyric(self, result: LyricSearchResult):
"""加载选中的歌词"""
try:
lyrics = MultiSourceLyricSearch.get_lyric_by_result(result)
if lyrics:
self._lyrics = lyrics
self.root.after(0, self._update_lyric_display)
self.root.after(0, lambda: self.lyric_source.config(text=f"来源: {result.source} - {result.title}"))
except Exception as e:
print(f"加载歌词失败: {e}")
def _auto_search_lyric(self, song: Song):
"""自动在线搜索歌词"""
try:
query = f"{song.title} {song.artist}".strip()
lyrics, source = MultiSourceLyricSearch.search_all_sources(query, "")
if lyrics:
self._lyrics = lyrics
self.root.after(0, self._update_lyric_display)
self.root.after(0, lambda: self.lyric_source.config(text=f"来源: {source}"))
except Exception as e:
print(f"自动搜索歌词失败: {e}")
def _update_loop(self):
try:
self._update_progress()
self._update_spectrum()
self._update_lyric()
self._check_end()
except tk.TclError:
pass # 窗口销毁后忽略
except Exception as e:
print(f"更新循环错误: {e}")
try:
self.root.after(50, self._update_loop)
except tk.TclError:
pass # 窗口已销毁
def _update_progress(self):
# 如果正在拖拽,跳过更新
if self._dragging:
return
song = self.playlist.get_current()
if not song: return
# 安全检查:pygame mixer 是否已初始化
if not pygame.mixer.get_init():
return
pos = self.audio.get_position()
# 动态获取时长(如果元数据加载失败)
dur = song.duration if song.duration > 0 else 300 # 默认5分钟
# 确保进度在有效范围内
if pos < 0: pos = 0
if pos > dur: pos = dur
try:
self.time_label.config(text=f"{int(pos//60):02d}:{int(pos%60):02d} / {int(dur//60):02d}:{int(dur%60):02d}")
except tk.TclError:
return
w = self.progress_canvas.winfo_reqwidth()
if w <= 0: w = 460
fill = int(w * min(1, pos / dur))
try:
self.progress_canvas.delete('all')
# 第1层:背景轨道
self.progress_canvas.create_rectangle(
0, 0, w, 15,
fill=self.COLORS.get('progress_bg', '#333366'),
outline=''
)
# 第2层:前景进度
self.progress_canvas.create_rectangle(
0, 0, fill, 15,
fill=self.COLORS['progress_fill'],
outline=''
)
# 第3层:圆点指示器
self.progress_canvas.create_oval(
fill - 6, 1, fill + 6, 14,
fill=self.COLORS['text'],
outline=self.COLORS['accent']
)
except tk.TclError:
return
def _update_spectrum(self):
"""更新频谱动画"""
self.spectrum_canvas.delete('all')
w = self.spectrum_canvas.winfo_width()
h = self.spectrum_canvas.winfo_height()
if w <= 0 or h <= 0: return
# 安全检查:pygame mixer 是否已初始化
if not pygame.mixer.get_init():
return
# 获取当前位置和时长
song = self.playlist.get_current()
pos = self.audio.get_position() if song else 0
dur = song.duration if song else 1
# 更新频谱引擎
self.spectrum_engine.update(self.audio.is_playing(), pos, dur)
values = self.spectrum_engine.get_values()
# 推进颜色相位
self.color_manager.advance_phase(0.05)
# 绘制频谱条
num_bars = len(values)
bar_w = (w - 30) // num_bars
gap = 2
for i, val in enumerate(values):
bar_h = int(val * h * 0.9)
x1 = i * (bar_w + gap) + 15
y1 = h - bar_h
# 获取渐变颜色
color = self.color_manager.get_bar_color(i, num_bars, val)
glow_color = self.color_manager.get_glow_color(i)
# 绘制发光效果
if bar_h > 5:
self.spectrum_canvas.create_rectangle(
x1-1, y1-2, x1+bar_w+1, h,
fill=glow_color, outline='', stipple='gray50'
)
# 绘制主频谱条
self.spectrum_canvas.create_rectangle(
x1, y1, x1+bar_w, h,
fill=color, outline=''
)
# 绘制顶部高亮
if bar_h > 3:
self.spectrum_canvas.create_rectangle(
x1, y1, x1+bar_w, y1+3,
fill=self.COLORS['text'], outline=''
)
def _update_lyric(self):
"""更新歌词滚动"""
if not self._lyrics:
# 只在第一次显示提示,避免重复刷新
if not self._lyrics_loading and hasattr(self, 'lyric_text'):
self._lyrics_loading = True
self.lyric_text.config(state=tk.NORMAL)
self.lyric_text.delete(1.0, tk.END)
self.lyric_text.insert(tk.END, "暂无歌词\n\n点击 🔍 搜索歌词\n或 点击 🔗 自动匹配")
self.lyric_text.config(state=tk.DISABLED)
return
self._lyrics_loading = False # 有歌词,取消加载标志
pos = self.audio.get_position()
new_idx = -1
for i, line in enumerate(self._lyrics):
if line.time <= pos: new_idx = i
else: break
if new_idx != self._lyric_index:
self._lyric_index = new_idx
self.lyric_text.config(state=tk.NORMAL)
self.lyric_text.tag_remove('hl', '1.0', tk.END)
if new_idx >= 0:
self.lyric_text.tag_add('hl', f"{new_idx+1}.0", f"{new_idx+1}.end")
# 平滑滚动到当前行
self._smooth_scroll_to_line(new_idx + 1)
self.lyric_text.tag_configure('hl', foreground=self.COLORS['text_gold'],
font=("Microsoft YaHei", 14, "bold"))
self.lyric_text.config(state=tk.DISABLED)
def _smooth_scroll_to_line(self, line_num):
"""平滑滚动到指定行"""
# 计算目标位置
total_lines = len(self._lyrics)
visible_lines = 10 # 可见行数估算
target = max(0, line_num - visible_lines // 2) / max(1, total_lines)
# 直接滚动(tkinter没有真正的平滑滚动API)
self.lyric_text.yview_moveto(target)
def _check_end(self):
"""检查音乐是否播放结束,触发下一曲或单曲循环
不使用 pygame.event.get()(需要 video system),改为纯 get_busy() + 位置检测。
"""
# 安全检查:pygame mixer 是否已初始化
if not pygame.mixer.get_init():
return
song = self.playlist.get_current()
if not song:
return
# 方法1:get_busy() 检测自然结束
# 注意:pause() 后 get_busy() 会返回 False,所以必须排除暂停状态
busy = pygame.mixer.music.get_busy()
if not busy and self.audio._playing and not self.audio._paused:
# 音乐自然播放结束(且不是暂停状态),触发下一曲
self._on_music_end()
return
# 方法2:位置超出时长(兜底,适用于 get_busy 漏检的情况)
if self.audio.is_playing():
pos = self.audio.get_position()
dur = song.duration if song.duration > 0 else 0
if dur > 0 and pos >= dur - 0.5:
self._on_music_end()
def _on_music_end(self):
"""音乐播放结束处理"""
self.audio._playing = False
if self.playlist.mode == PlaylistManager.MODE_SINGLE:
# 单曲循环:重新播放
song = self.playlist.get_current()
if song:
self._play(song)
elif self.playlist.mode == PlaylistManager.MODE_LOOP:
# 列表循环:下一曲
self._next()
elif self.playlist.mode == PlaylistManager.MODE_RANDOM:
# 随机:下一曲
self._next()
else:
# 顺序播放:尝试下一曲,没有则停止
song = self.playlist.next()
if song:
self._play(song)
else:
self.audio.stop()
self.btn_play.config(text="▶")
def run(self):
menubar = tk.Menu(self.root)
file_menu = tk.Menu(menubar, tearoff=0)
file_menu.add_command(label="打开文件...", command=self._open)
file_menu.add_separator()
file_menu.add_command(label="保存播放列表", command=self._save_playlist)
file_menu.add_command(label="加载播放列表", command=self._load_playlist)
file_menu.add_separator()
file_menu.add_command(label="退出", command=self._quit_and_save)
menubar.add_cascade(label="文件", menu=file_menu)
view_menu = tk.Menu(menubar, tearoff=0)
view_menu.add_command(label="播放列表", command=self._toggle_list)
view_menu.add_command(label="歌词", command=self._toggle_lyric)
view_menu.add_command(label="窗口磁吸", command=self.snap_manager.snap_all_to_main)
menubar.add_cascade(label="视图", menu=view_menu)
# 设置菜单
settings_menu = tk.Menu(menubar, tearoff=0)
# 透明度子菜单
opacity_menu = tk.Menu(settings_menu, tearoff=0)
for pct in [100, 90, 80, 70, 60, 50]:
opacity_menu.add_command(label=f"{pct}%",
command=lambda p=pct: self._set_opacity(p))
settings_menu.add_cascade(label="透明度", menu=opacity_menu)
settings_menu.add_separator()
settings_menu.add_command(label="退出并保存状态", command=self._quit_and_save)
menubar.add_cascade(label="设置", menu=settings_menu)
self.root.config(menu=menubar)
self.root.mainloop()
def _load_config(self) -> dict:
"""加载配置"""
try:
if self.CONFIG_FILE.exists():
with open(self.CONFIG_FILE, 'r', encoding='utf-8') as f:
return json.load(f)
except:
pass
return {'opacity': 1.0, 'bg_image': None}
def _save_config(self):
"""保存配置"""
try:
with open(self.CONFIG_FILE, 'w', encoding='utf-8') as f:
json.dump(self.config, f, ensure_ascii=False, indent=2)
except Exception as e:
print(f"保存配置失败: {e}")
def _apply_background(self):
"""应用背景图片"""
bg_path = self.config.get('bg_image')
if bg_path and Path(bg_path).exists():
try:
from PIL import Image, ImageTk
img = Image.open(bg_path)
# 调整大小以适应窗口
img = img.resize((480, 420), Image.Resampling.LANCZOS)
self.bg_image = ImageTk.PhotoImage(img)
if self.bg_label:
self.bg_label.destroy()
self.bg_label = tk.Label(self.root, image=self.bg_image, bg=self.COLORS['bg_dark'])
self.bg_label.place(x=0, y=0, relwidth=1, relheight=1)
self.bg_label.lower() # 放到最底层
except ImportError:
print("PIL未安装,无法使用背景图片")
except Exception as e:
print(f"加载背景图片失败: {e}")
elif self.bg_label:
self.bg_label.destroy()
self.bg_label = None
def _set_background(self):
"""选择背景图片"""
file = filedialog.askopenfilename(
title="选择背景图片",
filetypes=[("图片", "*.jpg *.jpeg *.png *.bmp *.gif")]
)
if file:
self.config['bg_image'] = file
self._save_config()
self._apply_background()
def _clear_background(self):
"""清除背景图片"""
self.config['bg_image'] = None
self._save_config()
self._apply_background()
def _set_opacity(self, val):
"""设置透明度"""
opacity = float(val) / 100
self.config['opacity'] = opacity
try:
self.root.attributes('-alpha', opacity)
except:
pass
self._save_config()
def _save_playlist(self):
"""保存播放列表"""
try:
data = {
'songs': [
{'path': s.path, 'title': s.title, 'artist': s.artist,
'album': s.album, 'duration': s.duration}
for s in self.playlist.songs
],
'current_index': self.playlist.current_index,
'mode': self.playlist.mode
}
with open(self.PLAYLIST_FILE, 'w', encoding='utf-8') as f:
json.dump(data, f, ensure_ascii=False, indent=2)
messagebox.showinfo("保存成功", f"播放列表已保存\n共 {len(self.playlist.songs)} 首歌曲")
except Exception as e:
messagebox.showerror("保存失败", f"无法保存播放列表: {e}")
def _load_playlist(self):
"""加载播放列表"""
try:
if not self.PLAYLIST_FILE.exists():
messagebox.showinfo("提示", "没有保存的播放列表")
return
with open(self.PLAYLIST_FILE, 'r', encoding='utf-8') as f:
data = json.load(f)
# 清空当前列表
self.playlist.clear()
self.listbox.delete(0, tk.END)
# 加载歌曲
for item in data.get('songs', []):
if Path(item['path']).exists():
song = Song(
path=item['path'],
title=item.get('title', ''),
artist=item.get('artist', ''),
album=item.get('album', ''),
duration=item.get('duration', 0)
)
self.playlist.add(song)
display = f"{song.title} - {song.artist}" if song.artist else song.title
self.listbox.insert(tk.END, display)
# 恢复播放位置和模式
self.playlist.current_index = data.get('current_index', -1)
mode = data.get('mode', PlaylistManager.MODE_ORDER)
while self.playlist.mode != mode:
self.playlist.cycle_mode()
self.btn_mode.config(text=self.MODE_INFO[self.playlist.mode][0])
messagebox.showinfo("加载成功", f"播放列表已加载\n共 {len(self.playlist.songs)} 首歌曲")
except Exception as e:
messagebox.showerror("加载失败", f"无法加载播放列表: {e}")
def _seek_start(self, event):
"""开始拖拽进度条"""
self._dragging = True
self._seek_drag(event)
def _seek_drag(self, event):
"""拖拽进度条(画两层:背景轨道 + 前景进度 + 指示点)"""
song = self.playlist.get_current()
if not song or song.duration <= 0: return
try:
w = self.progress_canvas.winfo_reqwidth()
if w <= 0: w = 460
x = max(0, min(event.x, w))
pos = (x / w) * song.duration
# 实时更新时间显示
self.time_label.config(text=f"{self._format_time(pos)} / {self._format_time(song.duration)}")
# --- 画两层:背景轨道(全宽) + 前景进度(当前位置) + 指示点 ---
self.progress_canvas.delete('all')
# 第1层:背景轨道(灰色)
self.progress_canvas.create_rectangle(
0, 0, w, 15,
fill=self.COLORS.get('progress_bg', '#3a3a5c'),
outline=''
)
# 第2层:前景进度(彩色)
self.progress_canvas.create_rectangle(
0, 0, x, 15,
fill=self.COLORS['progress_fill'],
outline=''
)
# 第3层:圆点指示器
self.progress_canvas.create_oval(
x - 6, 1, x + 6, 14,
fill=self.COLORS['text'],
outline=self.COLORS['accent']
)
except tk.TclError:
pass
def _seek_end(self, event):
"""结束拖拽进度条"""
song = self.playlist.get_current()
if song and song.duration > 0:
# 使用 winfo_reqwidth() 避免 winfo_width() 返回 0 的问题
w = self.progress_canvas.winfo_reqwidth()
if w <= 0: w = 460
x = max(0, min(event.x, w))
pos = (x / w) * song.duration
self.audio.set_position(max(0, pos))
# 拖拽结束后立即刷新进度条显示
self.root.after_idle(self._update_progress)
self._dragging = False
def _format_time(self, seconds: float) -> str:
"""格式化时间显示"""
mins = int(seconds // 60)
secs = int(seconds % 60)
return f"{mins:02d}:{secs:02d}"
def _quit_and_save(self):
"""退出并保存当前播放状态"""
try:
# 保存当前播放状态
state = {
'songs': [],
'current_index': -1,
'current_position': 0,
'play_mode': self.playlist.mode,
'volume': self.volume_scale.get() if hasattr(self, 'volume_scale') else 70,
'opacity': self.config.get('opacity', 1.0),
'bg_image': self.config.get('bg_image'),
}
# 保存播放列表
for i, song in enumerate(self.playlist.songs):
state['songs'].append({
'path': song.path,
'title': song.title,
'artist': song.artist,
'album': song.album,
'duration': song.duration,
})
state['current_index'] = self.playlist.current_index
# 保存当前播放进度
if hasattr(self, 'audio') and self.audio.is_playing():
state['current_position'] = self.audio.get_position()
# 清除可能残留的旧结束事件(安全调用)
try:
pygame.event.clear(pygame.USEREVENT + 1)
except pygame.error:
pass # event subsystem not available
# 写入状态文件
with open(self.STATE_FILE, 'w', encoding='utf-8') as f:
json.dump(state, f, ensure_ascii=False, indent=2)
# 保存配置
self._save_config()
except Exception as e:
print(f"保存状态失败: {e}")
# 关闭窗口
try:
self.root.destroy()
except:
pass
def _restore_state(self):
"""恢复上次退出时的播放状态"""
try:
if not self.STATE_FILE.exists():
return
with open(self.STATE_FILE, 'r', encoding='utf-8') as f:
state = json.load(f)
songs_data = state.get('songs', [])
if not songs_data:
return
# 恢复播放列表
valid_songs = []
for sd in songs_data:
if Path(sd['path']).exists():
song = Song(
path=sd['path'],
title=sd.get('title', ''),
artist=sd.get('artist', ''),
album=sd.get('album', ''),
)
song.duration = sd.get('duration', 0)
valid_songs.append(song)
self.playlist.add(song)
if not valid_songs:
return
# 恢复播放模式
mode = state.get('play_mode', PlaylistManager.MODE_CYCLE)
self.playlist.mode = mode
if hasattr(self, 'btn_mode'):
self.btn_mode.config(text=self.MODE_INFO[mode][0])
# 恢复音量
vol = state.get('volume', 70)
if hasattr(self, 'volume_scale'):
self.volume_scale.set(vol)
self.audio.set_volume(vol / 100)
# 恢复播放列表选中
idx = state.get('current_index', 0)
if 0 <= idx < len(self.playlist.songs):
self.list_var.set(self.playlist.songs[idx])
self.list_box.selection_set(idx)
self.playlist.current_index = idx
# 调度自动播放(在窗口稳定后)
self.root.after(500, self._delayed_restore_play, state)
except Exception as e:
print(f"恢复状态失败: {e}")
def _delayed_restore_play(self, state):
"""延迟自动播放上次曲目"""
try:
idx = state.get('current_index', 0)
if 0 <= idx < len(self.playlist.songs):
song = self.playlist.songs[idx]
self._play(song)
# 恢复播放位置(延迟一点确保音乐已加载)
pos = state.get('current_position', 0)
if pos > 0:
self.root.after(300, lambda: self.audio.set_position(pos))
except Exception as e:
print(f"恢复播放失败: {e}")
if __name__ == "__main__":
app = DiDiPlayerApp()
app.run()