怀念经典,一步步开发一个ttplayer千千静听,皮肤切换




SkinSelectDialog 皮肤切换代码完整解析
SkinSelectDialog 是 TTPlayer 的皮肤选择对话框,它扫描可用的皮肤资源,以列表 + 预览图的形式展示,并允许用户切换皮肤。切换逻辑主要围绕 selectedSkinSpec 字段展开——当用户点击“切换”按钮时,将选中的皮肤标识符(spec)存储到该字段,然后关闭对话框;调用方(通常是主窗口)读取该字段并加载新皮肤。
1. 整体流程概览
- 构造时扫描皮肤:
scanSkins()调用SkinLoader.listClasspathSkins()获取所有可用皮肤的 spec 列表,然后逐个读取皮肤内的Skin.xml提取名称、作者和预览图。 - UI 初始化:左侧 JList 显示皮肤名称,右侧显示预览图,下方显示名称和作者,底部有“切换”和“取消”按钮。
- 选择事件:点击列表项时更新预览图和信息。
- 切换操作:点击“切换”按钮时,
selectedSkinSpec = entries.get(idx).spec; dispose();。 - 外部读取:调用方(如
PlayerWindow)在对话框关闭后检查dialog.selectedSkinSpec并加载新皮肤。
2. 关键数据结构:SkinEntry
private static class SkinEntry {
String spec; // 资源标识符,如 "classpath:skin/default.skn" 或 "fs:/path/to/skin.skn"
String displayName; // 文件名(不含扩展名)
String skinName; // 从 XML 中读取的 <skin name="...">
String author; // 从 XML 中读取的 <skin author="...">
byte[] previewData; // 预览图数据(从 <player_window> 的 image 属性指向的图片加载)
}
spec 是皮肤的唯一标识,格式为 classpath:path/to/skin.skn 或 fs:/absolute/path/skin.skn,由 SkinLoader 统一管理。
3. 皮肤扫描与信息提取
3.1 scanSkins()
private void scanSkins() {
Set<String> seen = new HashSet<>();
for (String spec : SkinLoader.listClasspathSkins()) {
String name = spec.substring(spec.lastIndexOf('/') + 1);
if (!seen.add(name)) continue; // 去重
SkinEntry entry = readSkinInfo(spec);
if (entry != null) entries.add(entry);
}
entries.sort((a, b) -> a.displayName.compareToIgnoreCase(b.displayName));
}
- 调用
SkinLoader.listClasspathSkins()获取 spec 列表(通常来自 classpath 下的skin/目录)。 - 对每个 spec,提取文件名作为
displayName并去重(防止同一皮肤多次出现)。 - 调用
readSkinInfo(spec)解析详细信息,若成功则加入列表。 - 按显示名称排序。
3.2 readSkinInfo(String spec)
private SkinEntry readSkinInfo(String spec) {
SkinEntry entry = new SkinEntry();
entry.spec = spec;
String name = spec.substring(spec.lastIndexOf('/') + 1);
entry.displayName = name.endsWith(".skn") ? name.substring(0, name.length() - 4) : name;
Map<String, byte[]> zip = readZipEntries(spec);
if (zip == null) return null;
byte[] xmlData = zip.get("Skin.xml");
if (xmlData == null) xmlData = zip.get("skin.xml");
if (xmlData != null) {
String xmlStr = decodeXmlText(xmlData);
// 使用正则提取 <skin name="..."> 和 <skin author="...">
// 同时提取 <player_window image="..."> 中的图片名,并加载预览图
...
}
return entry;
}
- 创建
SkinEntry并设置 spec 和 displayName。 - 调用
readZipEntries(spec)将整个 .skn 文件(ZIP)解压到内存 Map。 - 从 Map 中获取
Skin.xml,解码后通过正则提取name、author以及<player_window>的image属性。 - 如果找到预览图文件名,则从 ZIP Map 中取出对应的图片数据存入
previewData。
3.3 readZipEntries(String spec)
private Map<String, byte[]> readZipEntries(String spec) {
InputStream is;
if (spec.startsWith("classpath:")) {
is = getClass().getClassLoader().getResourceAsStream(spec.substring(10));
} else if (spec.startsWith("fs:")) {
is = new FileInputStream(spec.substring(3));
} else return null;
// 使用 ZipInputStream 遍历,将每个条目读入 byte[] 放入 Map
}
- 根据 spec 前缀决定从 classpath 还是文件系统加载。
- 使用
ZipInputStream解压,将所有文件读入内存(仅用于预览,不保存为文件)。 - 返回文件名 → 字节数组的 Map。
3.4 decodeXmlText(byte[] data)
与 TtSkin 中的解码逻辑类似:检测 BOM、XML 声明中的编码、或根据中文字符判断,支持 UTF-8 和 GBK。
4. UI 组件与预览
4.1 initComponents()
布局采用 BorderLayout:
- 左侧:
JList显示皮肤名称列表(DefaultListModel)。 - 右侧:中央为
previewPanel(自定义 JPanel 绘制预览图),下方为nameLabel和authorLabel。 - 底部:
applyBtn(切换)和cancelBtn(取消),均使用自定义AntButton绘制。
4.2 预览面板 previewPanel
- 重写
paintComponent,若previewImg非空,按比例缩放绘制;否则显示“无预览”文字。 - 背景色为深色(
#1E1E1E),衬托预览图。
4.3 列表选择事件
list.addListSelectionListener(e -> {
if (e.getValueIsAdjusting()) return;
int idx = list.getSelectedIndex();
if (idx < 0 || idx >= entries.size()) return;
selected = entries.get(idx);
updatePreview(selected);
});
- 当用户选择列表项时,调用
updatePreview更新名称、作者和预览图。
4.4 updatePreview(SkinEntry entry)
private void updatePreview(SkinEntry entry) {
nameLabel.setText(entry.skinName != null ? entry.skinName : entry.displayName);
authorLabel.setText(entry.author != null ? Messages.get("skinSelect.authorPrefix") + entry.author : "");
previewImg = null;
if (entry.previewData != null) {
try { previewImg = ImageIO.read(new ByteArrayInputStream(entry.previewData)); }
catch (Exception e) { log.warn("..."); }
}
previewPanel.repaint();
}
- 优先显示从 XML 读取的
skinName,否则显示文件名。 - 作者同理。
- 从
entry.previewData解码为BufferedImage并刷新面板。
5. 皮肤切换的核心逻辑
5.1 “切换”按钮动作
applyBtn.addActionListener(e -> {
int idx = list.getSelectedIndex();
if (idx >= 0 && idx < entries.size()) {
selectedSkinSpec = entries.get(idx).spec;
dispose();
}
});
- 获取当前选中的
SkinEntry,将其spec赋值给selectedSkinSpec(公共字段)。 - 关闭对话框(
dispose())。
5.2 外部调用者如何使用
典型用法(例如在 PlayerWindow 中):
SkinSelectDialog dialog = new SkinSelectDialog(this);
dialog.setVisible(true);
String spec = dialog.selectedSkinSpec;
if (spec != null) {
// 加载新皮肤:使用 SkinLoader.load(spec) 或 TtSkin.loadFromClasspath(...)
// 然后刷新所有窗口(主窗口、迷你窗口、歌词窗口等)
}
外部可以根据 selectedSkinSpec 知道用户选择了哪个皮肤,并执行实际的加载和切换。
6. AntButton 自定义按钮详解
AntButton 是一个仿 Ant Design 风格的自定义按钮,分为 primary(蓝色主按钮)和默认(白底灰边)两种。
6.1 状态管理
boolean primary;
boolean hover;
boolean pressedIn;
- 通过鼠标监听器更新
hover和pressedIn状态,在paintComponent中根据状态绘制不同样式。
6.2 绘制逻辑(paintComponent)
- 使用
RoundRectangle2D创建圆角矩形(ARC=6)。 - 主按钮:
- 禁用时:灰色填充 + 灰边框。
- 启用时:背景为
PRIMARY(#1677FF),悬停PRIMARY_HOVER,按下PRIMARY_ACTIVE。 - 文字白色。
- 默认按钮:
- 禁用时:半透明填充 + 灰边框。
- 启用时:白色背景,悬停时边框变为
PRIMARY(蓝色强调),文字颜色变为蓝色。 - 按下时背景变暗。
- 文字绘制时考虑抗锯齿,颜色根据状态调整。
这种方式实现了一个轻量、漂亮的按钮,无需依赖外部图标库。
7. 总结:皮肤切换完整流程
- 扫描:
SkinSelectDialog构造时,通过SkinLoader.listClasspathSkins()获得所有皮肤 spec,然后逐个读取Skin.xml提取元数据和预览图,构建SkinEntry列表。 - 展示:在 UI 中,左侧列表显示皮肤名,右侧显示预览、名称和作者。
- 选择:用户点击列表项,触发
updatePreview更新预览。 - 切换:用户点击“切换”按钮,将当前选中皮肤的
spec存入selectedSkinSpec,然后关闭对话框。 - 加载:调用方检测到
selectedSkinSpec非空,通过SkinLoader或TtSkin加载新皮肤,并刷新所有窗口。
这种设计将皮肤选择与加载分离,使得对话框只负责“选”,外部负责“切”,符合单一职责原则。
8. 与其他模块的协作
SkinLoader:提供listClasspathSkins(),负责发现可用的皮肤资源(通常扫描 classpath 下skin/目录中的.skn文件)。TtSkin:在外部加载时使用loadFromClasspath()或load(File)实际解析皮肤数据。SkinWindow/MainWindow等:加载新皮肤后需要重建窗口 UI(重新创建SkinWindow实例或调用reload方法)。
License
本项目仅供学习和个人使用。皮肤资源版权归原作者所有。
「获取方式」:后台回复 ttplayer


浙公网安备 33010602011771号