最近把JavaFX重新捡起来,给女儿做了一台“音视频复读机”。起因很简单:她学英语,老师给的课文音频和配套视频经常要反复听某几句话,市面上的复读软件要么收费,要么广告满天飞,手动拖进度条一节课下来能拖几十次,效率低到让人崩溃。于是我想,干脆自己写一个:导入本地MP3或MP4,支持A-B点无限循环、变速播放、SRT字幕逐句跳转、波形条定位,最好还能按一下录音做跟读对比。
这个项目本身不算特别复杂,但JavaFX媒体模块的坑是真不少。前前后后踩了一周,做完整理出来,给想用JavaFX做播放器、复读机、听力工具或者语料标注工具的同学当一份参考清单。
1. 项目缘起与整体设计思路
1.1 为什么最后选了JavaFX
做桌面播放器,选型一般是四条路:Swing、JavaFX、VLCJ、JavaCV。
Swing我太熟了,但做播放器界面非常吃力,拖一个视频画面上去基本要靠第三方组件,进度条、音量条、全屏手势都得自己造轮子。JavaCV是OpenCV的Java封装,功能很强,但它的定位是图像处理,拿来做播放器属于大炮打蚊子,而且依赖一堆native库,部署起来头疼。
VLCJ是把VLC播放器嵌到Java窗口里,播放能力确实是天花板,但还是那个问题:一堆native依赖,用户电脑上没装VLC还没法跑。而JavaFX从8.0开始就内置了JavaFX Media框架,Media、MediaPlayer、MediaView三件套,音视频播放、进度控制、播放状态监听这些都是原生支持的,一套代码在Windows、macOS、Linux上都能跑。
做个简单的对比:
| 方案 | UI能力 | 媒体解码 | 部署成本 | 适用场景 |
|---|---|---|---|---|
| Swing + 第三方 | 弱 | 基本没有 | 低 | 传统桌面工具 |
| JavaFX + Media | 强 | 内置常用格式 | 低 | 学习工具、播放器 |
| VLCJ | 中 | 极强 | 高,依赖VLC | 视频播放器、转换器 |
| JavaCV | 中 | 强 | 高,依赖大量库 | 图像识别、流处理 |
结论很明确:我要做的是一个给普通人用的复读工具,不是专业播放器,JavaFX的Media框架完全够用,UI又好看,动态换肤也方便。就它了。
1.2 复读机到底要解决什么问题
老式磁带复读机大家应该都有印象,一个播放器,一个录音键,靠磁带的反复倒带来实现“复读”。数字复读机其实也是那几件事,只不过把物理按键换成了程序状态:
- A-B点循环:设置起点和终点,播放到终点自动跳回起点继续播
- 变速播放:慢速跟读、快速预览
- 逐句定位:按上一句/下一句,直接跳到对应句子
- 跟读对比:自己读一遍,回放听差异
- 可视定位:看波形或频谱,快速找到某句话的位置
当时我给自己定的功能优先级是:A-B点循环和变速最核心,先做;逐句跳转是刚需,第二做;跟读对比是加分项,最后做。
模块拆分也很简单:界面层、播放控制层、数据层。界面层负责按钮、进度条和事件响应;播放控制层封装MediaPlayer,对外暴露方法;数据层管字幕解析、标记点保存。这个分层看着常规,但在JavaFX里很容易被破坏掉,因为MediaPlayer的回调线程和UI线程交织在一起,稍不注意就把一堆播放逻辑塞在Controller里,后面改东西会非常痛苦。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 核心技术选型与关键原理
2.1 JavaFX媒体播放三件套
JavaFX的媒体播放体系由三个核心类组成:Media表示媒体源,MediaPlayer负责播放控制,MediaView用于显示视频画面。音频只有前两个,视频就需要MediaView。
MediaPlayer是一个有明确状态机的对象,状态包括:
- UNKNOWN(初始)
- READY(媒体就绪)
- PAUSED(暂停)
- PLAYING(播放中)
- STALLED(缓冲不足,播放卡住)
- END_OF_MEDIA(播完)
- HALTED(异常终止)
这个状态机是我踩坑最多的地方。很多诡异问题,比如“点了播放按钮没反应”、“暂停后seek不生效”,本质上都是因为没搞清当前状态。后来我在开发时把每个状态变更都打了日志,调试效率一下子提上来了。MediaPlayer在不同操作系统上调用的是不同的底层媒体栈:Windows上走WMF,macOS走AVFoundation,Linux走GStreamer。这意味着同一个MP4文件,在Windows上播放流畅,到Linux上可能没有声音,或者干脆无法解码。写跨平台使用时要特别留意。
2.2 复读机的灵魂:A-B点循环
A-B循环是复读机的核心功能,实现方案随手就能列三种:
- 用AnimationTimer每帧检查当前播放时间
- 用Timeline定时任务,比如每100毫秒检查一次
- 监听currentTimeProperty属性变化,每次变化都判断
第一种太重了,每帧调用一次检查,UI里面还有其他事件要处理,浪费CPU。第二种比较中庸,100毫秒检查一次完全够用,因为用户感知不到100毫秒的延迟。第三种看起来最优雅,实际不行,currentTimeProperty在播放时高频触发,每次都在JavaFX UI线程上跑判断逻辑,拖拽进度条时还会叠加刷新,界面明显卡顿。
我最终选择了Timeline方案。核心逻辑是这样的:
java复制Timeline loopChecker = new Timeline(
new KeyFrame(Duration.millis(100), event -> {
checkLoop();
})
);
loopChecker.setCycleCount(Animation.INDEFINITE);
loopChecker.play();
private void checkLoop() {
if (!repeatEnabled || startPoint == null || endPoint == null) {
return;
}
Du
