怀念经典,一步步开发一个ttplayer千千静听,声音控制栏绘制
好的,我们先从千千静听(TTPlayer)的音量控件讲起,包括它在皮肤 XML 中的定义方式,然后对 TtVolumeBar 这个类的源码进行逐行、深度剖析。

第一部分:千千静听的音量控件是什么?
1. 功能与视觉
在千千静听主窗口中,音量控制通常是一条水平滑块(Slider),位于播放控制按钮附近(如进度条下方或右侧)。用户拖动滑块上的旋钮(Thumb),可以实时调节系统音量(0~100%)。视觉上,滑块通常包含:
- 背景轨道(Bar):一条细长的条状背景。
- 填充区域(Fill):从左侧开始,根据当前音量值填充颜色(常见绿色或蓝色渐变),直观显示音量大小。
- 滑块按钮(Thumb):一个小图标(如圆形或矩形),用户拖拽它来改变音量。
2. 皮肤 XML 中的定义
在 Skin.xml 中,音量控件通常使用 <slider> 标签(或专有的 <volume>),并通过 bar_image、fill_image、thumb_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 对象的 barImage、fillImage、thumbImage 字段中。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 = w时srcW = 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; }
- 允许外部注册监听器,在用户释放鼠标时接收音量变化通知。
第三部分:如何设计
✅ 设计亮点
- 精简高效:仅用两个图片(填充+按钮)就实现了完整的音量滑块,且绘制逻辑简洁。
- 实时反馈:拖动时立即更新视觉,用户操作感流畅。
- 回调时机优化:只在
mouseReleased时触发外部回调,避免了频繁调用导致的性能开销(播放器内核可能涉及音频设备操作)。
第四部分:与 TtSkin / 其他控件的联动
TtVolumeBar的图片数据来源于TtSkin解析的Ctl对象中的barImage、fillImage、thumbImage字段。- 它不直接解析 XML,而是由上层 UI 构建器(如
SkinWindow)负责将对应图片数据传入构造方法。 - 与
TtButton、TtLed类似,都依赖SkinWindow.decodeBmp解码 BMP 并处理透明色。
总结
TtVolumeBar 是一个轻巧且功能完整的音量滑块组件,通过两张图片(填充和按钮)实现了基本的水平拖拽调节。虽然缺少对多帧状态的利用和垂直方向的支持,但在经典 TTPlayer 皮肤应用中表现稳定。代码结构清晰,易于扩展(如增加悬停状态或垂直模式)。对于开发类似自定义 Swing 滑块控件的场景,它是一个不错的参考模板。
License
本项目仅供学习和个人使用。皮肤资源版权归原作者所有。
「获取方式」:后台回复 ttplayer


浙公网安备 33010602011771号