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

image

image

image

image

SkinSelectDialog 皮肤切换代码完整解析

SkinSelectDialog 是 TTPlayer 的皮肤选择对话框,它扫描可用的皮肤资源,以列表 + 预览图的形式展示,并允许用户切换皮肤。切换逻辑主要围绕 selectedSkinSpec 字段展开——当用户点击“切换”按钮时,将选中的皮肤标识符(spec)存储到该字段,然后关闭对话框;调用方(通常是主窗口)读取该字段并加载新皮肤。


1. 整体流程概览

  1. 构造时扫描皮肤scanSkins() 调用 SkinLoader.listClasspathSkins() 获取所有可用皮肤的 spec 列表,然后逐个读取皮肤内的 Skin.xml 提取名称、作者和预览图。
  2. UI 初始化:左侧 JList 显示皮肤名称,右侧显示预览图,下方显示名称和作者,底部有“切换”和“取消”按钮。
  3. 选择事件:点击列表项时更新预览图和信息。
  4. 切换操作:点击“切换”按钮时,selectedSkinSpec = entries.get(idx).spec; dispose();
  5. 外部读取:调用方(如 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.sknfs:/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,解码后通过正则提取 nameauthor 以及 <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 绘制预览图),下方为 nameLabelauthorLabel
  • 底部: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;
  • 通过鼠标监听器更新 hoverpressedIn 状态,在 paintComponent 中根据状态绘制不同样式。

6.2 绘制逻辑(paintComponent

  • 使用 RoundRectangle2D 创建圆角矩形(ARC=6)。
  • 主按钮
    • 禁用时:灰色填充 + 灰边框。
    • 启用时:背景为 PRIMARY(#1677FF),悬停 PRIMARY_HOVER,按下 PRIMARY_ACTIVE
    • 文字白色。
  • 默认按钮
    • 禁用时:半透明填充 + 灰边框。
    • 启用时:白色背景,悬停时边框变为 PRIMARY(蓝色强调),文字颜色变为蓝色。
    • 按下时背景变暗。
  • 文字绘制时考虑抗锯齿,颜色根据状态调整。

这种方式实现了一个轻量、漂亮的按钮,无需依赖外部图标库。


7. 总结:皮肤切换完整流程

  1. 扫描SkinSelectDialog 构造时,通过 SkinLoader.listClasspathSkins() 获得所有皮肤 spec,然后逐个读取 Skin.xml 提取元数据和预览图,构建 SkinEntry 列表。
  2. 展示:在 UI 中,左侧列表显示皮肤名,右侧显示预览、名称和作者。
  3. 选择:用户点击列表项,触发 updatePreview 更新预览。
  4. 切换:用户点击“切换”按钮,将当前选中皮肤的 spec 存入 selectedSkinSpec,然后关闭对话框。
  5. 加载:调用方检测到 selectedSkinSpec 非空,通过 SkinLoaderTtSkin 加载新皮肤,并刷新所有窗口。

这种设计将皮肤选择与加载分离,使得对话框只负责“选”,外部负责“切”,符合单一职责原则。


8. 与其他模块的协作

  • SkinLoader:提供 listClasspathSkins(),负责发现可用的皮肤资源(通常扫描 classpath 下 skin/ 目录中的 .skn 文件)。
  • TtSkin:在外部加载时使用 loadFromClasspath()load(File) 实际解析皮肤数据。
  • SkinWindow / MainWindow:加载新皮肤后需要重建窗口 UI(重新创建 SkinWindow 实例或调用 reload 方法)。

License

本项目仅供学习和个人使用。皮肤资源版权归原作者所有。

「获取方式」:后台回复 ttplayer

image

posted @ 2026-08-28 11:53  tomj2ee  阅读(2)  评论(0)    收藏  举报