怀念经典,一步步开发一个ttplayer千千静听,声音控制栏绘制

好的,我们先从千千静听(TTPlayer)的音量控件讲起,包括它在皮肤 XML 中的定义方式,然后对 TtVolumeBar 这个类的源码进行逐行、深度剖析
image


第一部分:千千静听的音量控件是什么?

1. 功能与视觉

在千千静听主窗口中,音量控制通常是一条水平滑块(Slider),位于播放控制按钮附近(如进度条下方或右侧)。用户拖动滑块上的旋钮(Thumb),可以实时调节系统音量(0~100%)。视觉上,滑块通常包含:

  • 背景轨道(Bar):一条细长的条状背景。
  • 填充区域(Fill):从左侧开始,根据当前音量值填充颜色(常见绿色或蓝色渐变),直观显示音量大小。
  • 滑块按钮(Thumb):一个小图标(如圆形或矩形),用户拖拽它来改变音量。

2. 皮肤 XML 中的定义

Skin.xml 中,音量控件通常使用 <slider> 标签(或专有的 <volume>),并通过 bar_imagefill_imagethumb_image 等属性指定图片资源。以下是典型定义:

<main_window position="0,0,275,116" image="main.bmp">
    <!-- 音量滑块 -->
    <slider position="180,85,240,100" 
            bar_image="vol_bg.bmp" 
            fill_image="vol_fill.bmp" 
            thumb_image="vol_thumb.bmp" 
            vertical="false" />
</main_window>
  • position:控件的矩形区域(左、上、右、下),用于定位和初始尺寸。
  • bar_image:背景轨道图片(通常为细长条)。
  • fill_image:填充图片(根据音量值裁剪显示)。
  • thumb_image:滑块按钮图片(通常为 4 帧水平排列,但 TtVolumeBar 只取第一帧)。
  • vertical:是否垂直(true/false),但当前代码仅支持水平。

TtSkin 解析时,这些属性会被读取并填充到 Ctl 对象的 barImagefillImagethumbImage 字段中。UI 构建器再根据这些数据实例化 TtVolumeBar


第二部分:TtVolumeBar 源码完整深度剖析

1. 成员变量

private BufferedImage fillImg;
private BufferedImage thumbImg;
private int value = 70;
private boolean dragging = false;
private int thumbFrameW = 10;
private int thumbFrameH = 18;
  • fillImg:填充图片(从 fill_image 解码而来)。
  • thumbImg:滑块按钮图片(从 thumb_image 解码而来)。
  • value:当前音量值(0~100),默认 70。
  • dragging:是否正在拖拽(用于外部查询)。
  • thumbFrameW/H:滑块按钮的单帧宽度和高度(从图片宽度除以 4 得到)。

2. 构造方法

public TtVolumeBar(byte[] fillData, byte[] thumbData) {
    if (fillData != null) {
        fillImg = SkinWindow.decodeBmp(fillData, new Color(255, 0, 255));
    }
    if (thumbData != null) {
        thumbImg = SkinWindow.decodeBmp(thumbData, new Color(255, 0, 255));
        if (thumbImg != null) {
            thumbFrameW = thumbImg.getWidth() / 4;
            thumbFrameH = thumbImg.getHeight();
        }
    }
    // 绑定鼠标事件
    addMouseListener(new MouseAdapter() { ... });
    addMouseMotionListener(new MouseAdapter() { ... });
}
  • 透明色处理:统一使用洋红(#FF00FF)作为透明色,与皮肤规范一致。
  • Thumb 切分:虽然 thumbImg 包含 4 帧(正常、悬停、按下、禁用),但代码只取第一帧(索引 0)的尺寸作为单帧尺寸,实际绘制时也只绘制第一帧。这意味着滑块按钮不会随鼠标状态变化(简化了交互,但在某些皮肤中可能显得生硬)。
  • 默认尺寸:若图片加载失败,thumbFrameW/H 保留初始值(10x18),作为占位。

3. 鼠标事件(交互逻辑)

addMouseListener(new MouseAdapter() {
    @Override
    public void mousePressed(MouseEvent e) {
        updateValue(e.getX());
        dragging = true;
    }
    @Override
    public void mouseReleased(MouseEvent e) {
        dragging = false;
        if (listener != null) listener.onVolumeChanged(value);
    }
});
addMouseMotionListener(new MouseAdapter() {
    @Override
    public void mouseDragged(MouseEvent e) {
        updateValue(e.getX());
    }
});
  • 点击跳转:用户单击轨道的任意位置,updateValue 会根据鼠标 X 坐标计算音量,并立即更新。
  • 拖拽跟随:按住鼠标拖动时,持续调用 updateValue,实现平滑调节。
  • 释放回调:松开鼠标时,通过 VolumeListener 接口通知外部(如播放器内核)音量已改变。注意回调仅在 mouseReleased 中触发,而非每次拖动都触发,减少事件风暴。

4. 更新值(updateValue

private void updateValue(int mx) {
    int w = getWidth();
    int trackWidth = w - thumbFrameW;
    if (trackWidth > 0) {
        int thumbLeft = mx - thumbFrameW / 2;
        value = Math.max(0, Math.min(100, thumbLeft * 100 / trackWidth));
        repaint();
    }
}
  • 计算逻辑:将鼠标 X 坐标减去滑块按钮宽度的一半,作为滑块左边缘位置。然后映射到 [0, 100] 区间。
  • 边界保护:使用 Math.max/min 确保值不越界。
  • 触发重绘:调用 repaint() 刷新显示。

5. 公开 API

public void setValue(int v) { value = Math.max(0, Math.min(100, v)); repaint(); }
public int getValue() { return value; }
public boolean isDragging() { return dragging; }
  • setValue:允许外部程序(如加载保存的音量设置)强制设定音量,并自动重绘。
  • isDragging:用于外部判断当前是否正在拖动(例如避免在拖动时同时执行其他动画)。

6. 绘制逻辑(paintComponent)—— 核心

protected void paintComponent(Graphics g) {
    int w = getWidth();
    int h = getHeight();
    if (w <= 0 || h <= 0) return;

    int trackWidth = w - thumbFrameW;
    int thumbX = (trackWidth > 0) ? trackWidth * value / 100 : 0;
    int thumbY = (h - thumbFrameH) / 2;
    int fillEnd = thumbX + thumbFrameW / 2;

    // 1. 绘制填充区域
    if (fillImg != null && fillEnd > 0) {
        int fillH = fillImg.getHeight();
        int fillY = (h - fillH) / 2;
        int srcW = (fillImg.getWidth() - 1) * fillEnd / w + 1;
        if (srcW > fillImg.getWidth()) srcW = fillImg.getWidth();
        g.drawImage(fillImg,
                0, fillY, fillEnd, fillY + fillH,
                0, 0, srcW, fillH,
                null);
    }

    // 2. 绘制滑块按钮
    if (thumbImg != null) {
        g.drawImage(thumbImg, thumbX, thumbY, thumbX + thumbFrameW, thumbY + thumbFrameH,
                0, 0, thumbFrameW, thumbFrameH, null);
    }
}

6.1 尺寸计算

  • trackWidth = w - thumbFrameW:滑块可移动的轨道宽度(总宽度减去按钮宽度)。
  • thumbX = trackWidth * value / 100:根据当前音量值计算按钮左边缘 X 坐标。
  • thumbY:垂直居中。
  • fillEnd = thumbX + thumbFrameW / 2:填充区域的结束 X 坐标(约在按钮中心位置,使填充效果更自然)。

6.2 填充图片裁剪(srcW 的计算)

int srcW = (fillImg.getWidth() - 1) * fillEnd / w + 1;
  • 目的:根据填充区域的宽度比例,从 fillImg 中截取对应的左侧部分。fillImg 通常是一个从左到右渐变的图片(如从暗到亮)。
  • 公式解析
    • fillEnd / w 是当前填充比例。
    • 乘以 (fillImg.getWidth() - 1) 并加 1,是为了避免浮点误差,同时确保当 fillEnd = wsrcW = fillImg.getWidth(),即全宽。
  • 绘制:使用 drawImage 的重载版本,将源图片的 (0,0,srcW,fillH) 区域拉伸绘制到目标矩形 (0, fillY, fillEnd, fillY+fillH)。这样实现了填充区域随音量动态变化。

6.3 滑块按钮绘制

  • 直接从 thumbImg 中截取第一帧(0,0,thumbFrameW,thumbFrameH),绘制到计算出的 thumbX, thumbY 位置。
  • 注意:这里没有进行任何状态切换(悬停/按下),始终显示同一帧。

7. 接口 VolumeListener

public interface VolumeListener { void onVolumeChanged(int percent); }
private VolumeListener listener;
public void setVolumeListener(VolumeListener l) { this.listener = l; }
  • 允许外部注册监听器,在用户释放鼠标时接收音量变化通知。

第三部分:如何设计

✅ 设计亮点

  1. 精简高效:仅用两个图片(填充+按钮)就实现了完整的音量滑块,且绘制逻辑简洁。
  2. 实时反馈:拖动时立即更新视觉,用户操作感流畅。
  3. 回调时机优化:只在 mouseReleased 时触发外部回调,避免了频繁调用导致的性能开销(播放器内核可能涉及音频设备操作)。

第四部分:与 TtSkin / 其他控件的联动

  • TtVolumeBar 的图片数据来源于 TtSkin 解析的 Ctl 对象中的 barImagefillImagethumbImage 字段。
  • 它不直接解析 XML,而是由上层 UI 构建器(如 SkinWindow)负责将对应图片数据传入构造方法。
  • TtButtonTtLed 类似,都依赖 SkinWindow.decodeBmp 解码 BMP 并处理透明色。

总结

TtVolumeBar 是一个轻巧且功能完整的音量滑块组件,通过两张图片(填充和按钮)实现了基本的水平拖拽调节。虽然缺少对多帧状态的利用和垂直方向的支持,但在经典 TTPlayer 皮肤应用中表现稳定。代码结构清晰,易于扩展(如增加悬停状态或垂直模式)。对于开发类似自定义 Swing 滑块控件的场景,它是一个不错的参考模板。

License

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

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

image

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