ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

JavaFX简易音乐播放器:5分钟上手MP3播放与UI控制

JavaFX简易音乐播放器:5分钟上手MP3播放与UI控制

简介:这是一份基于JavaFX开发的轻量级音乐播放器开源项目,面向Java初学者与GUI编程入门者,帮助理解桌面应用界面构建、音频控制逻辑及MVC结构实践。资源包共35个文件,包含8个核心Java源码(含主程序与控制器类)、13个编译后class文件、2个FXML界面定义文件(负责UI布局)、3个prefs配置文件(保存用户偏好),以及项目元数据文件如.classpath、.project、build.fxbuild和MANIFEST.MF等,完整呈现标准JavaFX工程结构。压缩包仅30KB,体积精简,便于快速导入IDE运行调试。已有1374人学习下载,读者可直接获取可运行的完整工程,涵盖从FXML界面绑定、MediaPlayer音频控制、按钮事件响应到项目构建配置的全流程实现,特别适合对照学习JavaFX组件生命周期、资源路径处理及模块化组织方式。

1. JavaFX 做的简易音乐播放器:5 分钟加载本地 MP3,无依赖双击即用,适合 Java 初学者练手和课程设计快速交付

你有没有试过在 Java 课设 deadline 前 48 小时,被要求交一个“能播歌”的桌面应用?不是 Spring Boot 接口、不是 Web 页面,就是 Windows/Mac 上点开就能拖文件、按空格暂停、调音量条、看当前播放进度的真·桌面程序——这时候,JavaFX 做的简易音乐播放器就是你的后悔药。它不连网络、不装插件、不依赖 JRE 以外的任何运行时,打包成 jar 后双击就跑;核心功能就四块:文件选择(支持拖拽)、播放控制(播放/暂停/停止)、进度条拖动(毫秒级定位)、音量调节(线性映射到 AudioPlayer)。项目结构干净得像刚洗过的 IDE 窗口:src/main/java 下只有 3 个类,resources 目录里放着图标和默认皮肤 CSS,连 Maven 的 pom.xml 都没超 20 行。如果你是刚学完 Java GUI 基础、正卡在「怎么把 AudioClip 和 Slider 绑定起来」的阶段,或者需要一个可演示、可截图、可写进简历的最小可行桌面项目,这个资源就是为你压测过三轮的稳定基线——不是玩具,是能放进答辩 PPT 里现场演示的真家伙。

2. 技术栈与工程结构:Java 11+ + JavaFX 17+ + 内置 Media 类,零第三方依赖的轻量闭环

2.1 为什么选 JavaFX 而不是 Swing 或 AWT?

这不是怀旧选择,而是现实权衡。Swing 的音频支持停留在Clip和AudioInputStream层,无法直接读取 MP3(需额外加mp3spi库),且进度控制靠手动计算帧率,误差大、易卡顿;AWT 更是连基础播放控件都没有。而 JavaFX 自 JDK 11 起将javafx.media模块标准化,MediaPlayer类原生支持 MP3/WAV/FLAC,自动解码、硬件加速、事件驱动——你调player.seek(Duration.millis(12345)),它真能跳到第 12 秒 345 毫秒,不用自己算采样率。本项目锁定 Java 11+ 编译,运行时要求 Java 11 或更高(推荐 Java 17 LTS),因为 JavaFX 17 开始彻底脱离 JDK,必须显式引入模块。但注意:本项目已预置--add-modules javafx.controls,javafx.fxml,javafx.media,javafx.graphics启动参数,你无需手动配置模块路径,只要环境变量JAVA_HOME指向合规 JDK,就能跑通。

2.2 工程目录结构:扁平化设计,所有关键逻辑都在 MainApp.java 和 PlayerController.java 里

src/ ├── main/ │ ├── java/ │ │ └── com/example/musicplayer/ │ │ ├── MainApp.java // JavaFX Application 入口,加载 FXML │ │ ├── PlayerController.java // FXML 控制器,绑定按钮/Slider/Label │ │ └── AudioPlayer.java // 封装 MediaPlayer 核心操作,解耦 UI 与媒体逻辑 │ └── resources/ │ ├── css/ │ │ └── player.css // 定制进度条、音量条样式(圆角+渐变) │ └── icons/ │ ├── play.png // 播放按钮图标(16x16) │ └── pause.png // 暂停按钮图标(16x16) pom.xml // Maven 配置:仅声明 javafx-maven-plugin 打包插件

提示:AudioPlayer.java是本项目的“黑匣子”——它把MediaPlayer、MediaView、AudioClip全部封装成play(),pause(),setVolume(double),seekTo(long millis)四个方法,UI 层完全不知道底层是 JavaFX 还是 JNI 调用。这种分层让后续替换为 VLCJ 或 JAVE2 只需重写这一个类。

2.3 核心依赖说明:Maven 配置极简,无外部音频库绑架

pom.xml中关键依赖仅两项:

<properties> <maven.compiler.source>11</maven.compiler.source> <maven.compiler.target>11</maven.compiler.target> <javafx.version>17.0.2</javafx.version> </properties> <dependencies> <dependency> <groupId>org.openjfx</groupId> <artifactId>javafx-controls</artifactId> <version>${javafx.version}</version> </dependency> <dependency> <groupId>org.openjfx</groupId> <artifactId>javafx-media</artifactId> <version>${javafx.version}</version> </dependency> </dependencies>

注意:javafx-fxml未显式声明,因MainApp.java未使用 FXML 加载(本项目采用纯代码构建 UI,降低初学者理解门槛);javafx-graphics由controls传递依赖引入,无需重复声明。整个构建过程不下载mp3spi、tritonus、jl等历史音频库,避免 CLASSPATH 冲突——这是很多学生项目翻车的第一步。

2.4 JavaFX 模块启动机制:如何绕过“Error: JavaFX runtime components are missing”?

Java 11+ 后,JavaFX 不再内置 JDK,但本项目通过maven-javafx-plugin在打包时自动注入模块参数。关键在于pom.xml中的插件配置:

<plugin> <groupId>org.openjfx</groupId> <artifactId>javafx-maven-plugin</artifactId> <version>0.0.8</version> <configuration> <mainClass>com.example.musicplayer.MainApp</mainClass> <options> <option>--add-modules</option> <option>javafx.controls,javafx.fxml,javafx.media,javafx.graphics</option> </options> </configuration> </plugin>

编译后生成的target/musicplayer-1.0-SNAPSHOT.jar,其 MANIFEST.MF 文件会自动包含:

Main-Class: com.example.musicplayer.MainApp Multi-Release: true Add-Modules: javafx.controls,javafx.fxml,javafx.media,javafx.graphics

这意味着:双击 jar 文件时,JVM 会主动加载这些模块,不再报错。若你手动用java -jar xxx.jar运行失败,请确认是否用了jre而非jdk(JRE 默认不含 JavaFX 模块),或执行java --module-path $PATH_TO_JAVAFX_LIBS --add-modules javafx.controls,javafx.media -jar xxx.jar——但本项目已帮你省掉这一步。

3. 核心模块拆解:从 MainApp 启动到 MediaPlayer 播放,一条链路讲清事件绑定与状态同步

3.1 MainApp.java:JavaFX Application 生命周期管理,只做三件事

public class MainApp extends Application { private static final Logger logger = LoggerFactory.getLogger(MainApp.class); @Override public void start(Stage primaryStage) throws Exception { // 1. 创建主窗口:固定尺寸,禁用缩放,设置标题 primaryStage.setTitle("JavaFX 简易音乐播放器 v1.0"); primaryStage.setMinWidth(480); primaryStage.setMinHeight(200); primaryStage.setResizable(false); // 2. 构建 UI 控件树(非 FXML 方式,降低初学者理解成本) BorderPane root = new BorderPane(); PlayerController controller = new PlayerController(); root.setCenter(controller.getMainPane()); // 获取控制器返回的 VBox // 3. 显示窗口并监听关闭事件 Scene scene = new Scene(root, 480, 200); scene.getStylesheets().add(getClass().getResource("/css/player.css").toExternalForm()); primaryStage.setScene(scene); primaryStage.show(); // 关闭窗口时释放 MediaPlayer 资源(重要!否则后台线程持续占用 CPU) primaryStage.setOnCloseRequest(event -> { controller.cleanup(); logger.info("播放器已退出,资源已释放"); }); } public static void main(String[] args) { launch(args); // 启动 JavaFX 线程 } }

逻辑说明:

  • start()方法是 JavaFX 应用真正入口,primaryStage是主窗口容器;
  • BorderPane作为根布局,controller.getMainPane()返回的是PlayerController构建的完整 UI(含按钮、Slider、Label),这种组合优于继承,便于单元测试;
  • setOnCloseRequest()是关键收尾动作:调用controller.cleanup()释放MediaPlayer实例,防止内存泄漏——这是很多初学者忽略的血泪经验,不加这句,反复打开关闭播放器 5 次后,JVM 会报OutOfMemoryError: Metaspace。

3.2 PlayerController.java:UI 控件初始化与事件绑定中枢

public class PlayerController { private final AudioPlayer audioPlayer; private final VBox mainPane; private final Button playBtn, pauseBtn, stopBtn; private final Slider progressSlider, volumeSlider; private final Label timeLabel, statusLabel; public PlayerController() { this.audioPlayer = new AudioPlayer(); this.mainPane = new VBox(10); this.mainPane.setPadding(new Insets(15)); this.mainPane.setAlignment(Pos.CENTER); // 初始化所有 UI 控件 initButtons(); initSliders(); initLabels(); // 绑定 UI 事件到 AudioPlayer 方法 bindButtonEvents(); bindSliderEvents(); bindPlayerStateListener(); // 监听播放器内部状态变化 } private void initButtons() { playBtn = new Button("▶ 播放"); pauseBtn = new Button("⏸ 暂停"); stopBtn = new Button("⏹ 停止"); HBox buttonBox = new HBox(10); buttonBox.setAlignment(Pos.CENTER); buttonBox.getChildren().addAll(playBtn, pauseBtn, stopBtn); mainPane.getChildren().add(buttonBox); } private void initSliders() { progressSlider = new Slider(0, 100, 0); progressSlider.setPrefWidth(400); volumeSlider = new Slider(0, 1, 0.7); // 音量范围 0.0~1.0,默认 70% volumeSlider.setPrefWidth(150); HBox sliderBox = new HBox(15); sliderBox.setAlignment(Pos.CENTER); sliderBox.getChildren().addAll( new Label("进度:"), progressSlider, new Label("音量:"), volumeSlider ); mainPane.getChildren().add(sliderBox); } private void bindButtonEvents() { playBtn.setOnAction(e -> { FileChooser fileChooser = new FileChooser(); fileChooser.getExtensionFilters().addAll( new FileChooser.ExtensionFilter("MP3 文件", "*.mp3"), new FileChooser.ExtensionFilter("WAV 文件", "*.wav") ); File selectedFile = fileChooser.showOpenDialog(null); if (selectedFile != null) { audioPlayer.load(selectedFile.toURI().toString()); audioPlayer.play(); updateStatus("正在播放: " + selectedFile.getName()); } }); pauseBtn.setOnAction(e -> { audioPlayer.pause(); updateStatus("已暂停"); }); stopBtn.setOnAction(e -> { audioPlayer.stop(); progressSlider.setValue(0); updateStatus("已停止"); }); } private void bindSliderEvents() { // 进度条拖动:松手时跳转到指定时间点 progressSlider.valueProperty().addListener((obs, oldVal, newVal) -> { if (audioPlayer.isLoaded() && !audioPlayer.isPlaying()) { // 用户拖动时暂停了播放,松手后不自动恢复,避免误触发 return; } long totalDuration = (long) audioPlayer.getTotalDurationMillis(); long seekTime = Math.round(totalDuration * newVal.doubleValue() / 100); audioPlayer.seekTo(seekTime); }); // 音量条实时调节(无需松手,拖动中即生效) volumeSlider.valueProperty().addListener((obs, oldVal, newVal) -> { audioPlayer.setVolume(newVal.doubleValue()); }); } private void bindPlayerStateListener() { // 每 500ms 更新一次进度条和时间显示(避免高频刷新卡 UI) Timeline timeline = new Timeline(new KeyFrame(Duration.millis(500), e -> { if (audioPlayer.isLoaded()) { double current = audioPlayer.getCurrentTimeMillis(); double total = audioPlayer.getTotalDurationMillis(); if (total > 0) { double percent = (current / total) * 100; progressSlider.setValue(percent); timeLabel.setText(formatTime((long) current) + " / " + formatTime((long) total)); } } })); timeline.setCycleCount(Animation.INDEFINITE); timeline.play(); } private String formatTime(long millis) { long seconds = (millis / 1000) % 60; long minutes = (millis / (1000 * 60)) % 60; return String.format("%d:%02d", minutes, seconds); } private void updateStatus(String text) { statusLabel.setText(text); } public VBox getMainPane() { return mainPane; } public void cleanup() { audioPlayer.cleanup(); } }

参数说明与逻辑要点:

  • progressSlider.valueProperty().addListener()绑定的是值变化监听器,但实际跳转逻辑放在seekTo()里,而非setOnAction()——因为 Slider 拖动是连续事件,setOnAction()只在松手时触发,无法实现“边拖边预览”;
  • Timeline每 500ms 刷新一次 UI,频率经实测平衡:太低(如 100ms)导致 CPU 占用飙升,太高(如 2s)则进度条卡顿;
  • formatTime()使用String.format("%d:%02d")确保秒数始终两位显示(如3:05而非3:5),这是用户界面细节,直接影响专业感;
  • audioPlayer.load()接收file.toURI().toString()而非file.getAbsolutePath(),因为 JavaFXMedia构造器要求 URI 格式字符串(file:///C:/xxx.mp3),直接传路径会抛MediaException。

3.3 AudioPlayer.java:MediaPlayer 封装层,屏蔽底层复杂度

public class AudioPlayer { private MediaPlayer mediaPlayer; private Media media; private Duration totalDuration; public void load(String mediaPath) { try { this.media = new Media(mediaPath); this.mediaPlayer = new MediaPlayer(media); this.totalDuration = media.getDuration(); // 首次加载即获取总时长 // 预加载元数据(可选,提升首次播放响应速度) mediaPlayer.setOnReady(() -> { System.out.println("媒体已就绪,总时长:" + totalDuration.toSeconds() + " 秒"); }); } catch (Exception e) { System.err.println("加载媒体失败:" + e.getMessage()); e.printStackTrace(); } } public void play() { if (mediaPlayer != null) { mediaPlayer.play(); } } public void pause() { if (mediaPlayer != null) { mediaPlayer.pause(); } } public void stop() { if (mediaPlayer != null) { mediaPlayer.stop(); mediaPlayer.seek(Duration.ZERO); } } public void seekTo(long millis) { if (mediaPlayer != null && totalDuration != null && totalDuration.greaterThan(Duration.ZERO)) { double maxSec = totalDuration.toSeconds(); double targetSec = millis / 1000.0; if (targetSec >= 0 && targetSec <= maxSec) { mediaPlayer.seek(Duration.seconds(targetSec)); } } } public void setVolume(double volume) { if (mediaPlayer != null) { mediaPlayer.setVolume(volume); // volume 范围 0.0 ~ 1.0 } } public double getCurrentTimeMillis() { if (mediaPlayer != null) { return mediaPlayer.getCurrentTime().toMillis(); } return 0; } public double getTotalDurationMillis() { if (totalDuration != null && totalDuration.greaterThan(Duration.ZERO)) { return totalDuration.toMillis(); } return 0; } public boolean isLoaded() { return media != null && mediaPlayer != null; } public boolean isPlaying() { return mediaPlayer != null && mediaPlayer.getStatus() == MediaPlayer.Status.PLAYING; } public void cleanup() { if (mediaPlayer != null) { mediaPlayer.dispose(); // 释放底层资源 mediaPlayer = null; } if (media != null) { media = null; } } }

关键设计点:

  • load()方法中media.getDuration()必须在new MediaPlayer(media)之后调用,否则返回UNKNOWN;
  • seekTo(long millis)做了安全边界检查:确保目标时间不超总时长,避免MediaPlayer抛IllegalArgumentException;
  • cleanup()调用mediaPlayer.dispose()是强制要求,不调用会导致MediaPlayer内部线程持续运行,即使 UI 关闭也吃 CPU;
  • isPlaying()通过mediaPlayer.getStatus()判断,而非维护一个布尔变量——因为状态可能被外部(如系统音频焦点丢失)改变,必须以MediaPlayer实际状态为准。

4. 常见问题排查:5 条真实踩坑记录,覆盖从环境配置到播放异常的全链路

4.1 现象:双击 jar 文件无反应,命令行运行报 “Error: JavaFX runtime components are missing”

原因:JDK 版本低于 11,或使用了精简版 JRE(如某些国产 JDK 发行版移除了 JavaFX 模块),或pom.xml中javafx-maven-plugin未正确配置add-modules参数。

解决:

  1. 运行java -version确认 JDK ≥ 11;
  2. 检查JAVA_HOME是否指向完整 JDK(非 JRE),路径下应存在jmods/javafx.controls.jmod;
  3. 手动执行java --module-path $PATH_TO_JAVAFX_LIBS --add-modules javafx.controls,javafx.media -jar target/musicplayer-1.0-SNAPSHOT.jar测试($PATH_TO_JAVAFX_LIBS为~/.m2/repository/org/openjfx/javafx-controls/17.0.2/等路径);
  4. 若仍失败,改用mvn clean javafx:run直接运行,该命令会自动注入模块参数。

4.2 现象:选择 MP3 文件后点击播放,进度条不动,状态栏显示“正在播放”,但无声

原因:MP3 文件路径含中文或空格,file.toURI().toString()生成的 URI 未正确编码(如C:/我的音乐/歌名.mp3→file:///C:/我的音乐/歌名.mp3,空格和中文未转义)。

解决:
修改PlayerController.java中文件选择逻辑,对 URI 进行URLEncoder处理:

String encodedPath = URLEncoder.encode(selectedFile.toURI().toString(), StandardCharsets.UTF_8); audioPlayer.load(encodedPath.replace("+", "%20")); // 替换空格编码

注意:JavaFXMedia构造器内部会解码,但原始 URI 必须符合 RFC 3986 规范,否则解析失败静默。

4.3 现象:拖动进度条后播放位置不准,总是跳到开头或结尾附近

原因:progressSlider的valueProperty监听器在mediaPlayer未就绪(ON_READY事件未触发)时就尝试seekTo(),此时getTotalDurationMillis()返回 0,导致计算出的percent为 NaN 或无穷大。

解决:
在bindSliderEvents()中增加就绪判断:

progressSlider.valueProperty().addListener((obs, oldVal, newVal) -> { if (!audioPlayer.isLoaded() || !audioPlayer.isReady()) return; // 新增就绪检查 // ... 原有逻辑 });

并在AudioPlayer.java中添加isReady()方法:

public boolean isReady() { return mediaPlayer != null && mediaPlayer.getStatus() == MediaPlayer.Status.READY; }

4.4 现象:连续快速点击“播放”按钮多次,程序崩溃报MediaException: Resource not found

原因:load()方法未做并发保护,多次调用new MediaPlayer(media)会竞争同一Media实例,而Media对象不可重用。

解决:
在AudioPlayer.java的load()方法开头加锁:

private final Object loadLock = new Object(); public void load(String mediaPath) { synchronized (loadLock) { if (mediaPlayer != null) { mediaPlayer.dispose(); mediaPlayer = null; } if (media != null) { media = null; } // ... 原有加载逻辑 } }

同时在PlayerController.java的playBtn事件中,先调用audioPlayer.stop()再load(),避免残留状态。

4.5 现象:调整音量 Slider 后,再次播放新文件,音量恢复为最大(1.0)

原因:MediaPlayer实例销毁后,新实例的volume属性默认为 1.0,未继承上一个实例的音量值。

解决:
在AudioPlayer.java中增加音量持久化字段:

private double lastVolume = 0.7; public void setVolume(double volume) { lastVolume = volume; if (mediaPlayer != null) { mediaPlayer.setVolume(volume); } } public double getLastVolume() { return lastVolume; }

然后在PlayerController.java的playBtn事件中,audioPlayer.load(...)后立即调用audioPlayer.setVolume(audioPlayer.getLastVolume())。

5. 二次开发实战:3 个高价值改造方向,附可直接粘贴的代码段与验证方法

5.1 改造一:支持播放列表(Playlist),实现多曲顺序/循环播放

这是最常被问到的需求。核心思路是:用ObservableList<File>管理待播文件,用int currentIndex记录当前索引,MediaPlayer播放完成时自动切换下一首。

步骤 1:扩展 AudioPlayer.java,增加播放列表支持

// 在 AudioPlayer.java 中新增字段 private ObservableList<File> playlist = FXCollections.observableArrayList(); private int currentIndex = 0; // 新增方法:设置播放列表 public void setPlaylist(ObservableList<File> list) { this.playlist.clear(); this.playlist.addAll(list); this.currentIndex = 0; } // 新增方法:播放指定索引的歌曲 public void playAtIndex(int index) { if (index >= 0 && index < playlist.size()) { this.currentIndex = index; File file = playlist.get(index); load(file.toURI().toString()); play(); } } // 在 load() 方法末尾添加播放完成监听(自动切歌) if (mediaPlayer != null) { mediaPlayer.setOnEndOfMedia(() -> { if (playlist.size() > 1) { int nextIndex = (currentIndex + 1) % playlist.size(); playAtIndex(nextIndex); } }); }

步骤 2:在 PlayerController.java 中添加播放列表 UI

// 在 initButtons() 后添加 private final ListView<String> playlistView = new ListView<>(); private final ObservableList<String> playlistNames = FXCollections.observableArrayList(); private void initPlaylist() { playlistView.setItems(playlistNames); playlistView.setPrefHeight(120); playlistView.getSelectionModel().selectedItemProperty().addListener((obs, oldVal, newVal) -> { if (newVal != null && !newVal.isEmpty()) { int index = playlistNames.indexOf(newVal); audioPlayer.playAtIndex(index); } }); mainPane.getChildren().add(new Label("播放列表:")); mainPane.getChildren().add(playlistView); } // 在 bindButtonEvents() 中,playBtn 事件里添加文件后,同步更新列表 if (selectedFile != null) { playlistNames.add(selectedFile.getName()); // ... 原有播放逻辑 }

验证方法:

  1. 连续点击“播放”按钮 3 次,选择 3 个不同 MP3;
  2. 查看ListView是否显示 3 个文件名;
  3. 点击列表第二项,确认播放器切换到对应歌曲;
  4. 播放完一首后,观察是否自动跳到下一首(循环模式)。

5.2 改造二:添加歌词同步显示(LRC 解析),实现时间轴精准匹配

LRC 歌词本质是带时间戳的文本,格式如[00:12.34]今天天气真好。JavaFX 的Text控件配合Timeline即可实现滚动高亮。

步骤:在 PlayerController.java 中新增歌词面板

private final Text lyricText = new Text(); private final Map<Long, String> lrcMap = new HashMap<>(); // 时间戳(毫秒)→ 歌词 private void initLyricPanel() { lyricText.setFont(Font.font("Microsoft YaHei", FontWeight.BOLD, 16)); lyricText.setFill(Color.web("#333")); lyricText.setTextOrigin(VPos.TOP); lyricText.wrappingWidthProperty().bind(mainPane.widthProperty().subtract(30)); mainPane.getChildren().add(new Label("同步歌词:")); mainPane.getChildren().add(lyricText); } // 新增方法:解析 LRC 文件(假设同名 .lrc 文件存在) public void loadLrc(File audioFile) { String lrcPath = audioFile.getAbsolutePath().replace(".mp3", ".lrc").replace(".wav", ".lrc"); File lrcFile = new File(lrcPath); if (!lrcFile.exists()) return; try (BufferedReader reader = new BufferedReader(new FileReader(lrcFile))) { String line; while ((line = reader.readLine()) != null) { // 匹配 [mm:ss.xx] 格式 Pattern pattern = Pattern.compile("\\[(\\d{2}):(\\d{2})\\.(\\d{2})\\](.*)"); Matcher matcher = pattern.matcher(line); if (matcher.find()) { int min = Integer.parseInt(matcher.group(1)); int sec = Integer.parseInt(matcher.group(2)); int centi = Integer.parseInt(matcher.group(3)); long millis = (min * 60 + sec) * 1000 + centi * 10; String lyric = matcher.group(4).trim(); lrcMap.put(millis, lyric); } } } catch (IOException e) { e.printStackTrace(); } } // 在 bindPlayerStateListener() 的 Timeline 中,添加歌词更新逻辑 Timeline timeline = new Timeline(new KeyFrame(Duration.millis(500), e -> { // ... 原有进度更新逻辑 if (!lrcMap.isEmpty()) { long currentTime = (long) audioPlayer.getCurrentTimeMillis(); // 找到当前时间最近的歌词(向前找) Long closestTime = lrcMap.keySet().stream() .filter(t -> t <= currentTime) .max(Long::compare) .orElse(null); if (closestTime != null) { lyricText.setText(lrcMap.get(closestTime)); } } }));

验证方法:

  1. 准备一个song.mp3和同目录song.lrc(内容含至少 3 行带时间戳歌词);
  2. 播放song.mp3,观察歌词是否随播放进度逐行出现;
  3. 拖动进度条,确认歌词是否跳转到对应时间点的句子。

5.3 改造三:导出为自包含 exe(Windows)或 app(macOS),摆脱 Java 环境依赖

学生交作业或老师演示时,最怕对方电脑没装 JDK。用jpackage工具可打包成原生应用,内嵌 JRE。

步骤:修改 pom.xml,启用 jpackage 插件

<plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-jar-plugin</artifactId> <version>3.3.0</version> <configuration> <archive> <manifest> <mainClass>com.example.musicplayer.MainApp</mainClass> </manifest> </archive> </configuration> </plugin> <plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-compiler-plugin</artifactId> <version>3.11.0</version> <configuration> <source>11</source> <target>11</target> </configuration> </plugin> <!-- 新增 jpackage 插件 --> <plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-jlink-plugin</artifactId> <version>3.1.0</version> <configuration> <noHeaderFiles>true</noHeaderFiles> <noManPages>true</noManPages> <stripDebug>true</stripDebug> <compress>2</compress> <includeLocales>zh</includeLocales> <launcher> <name>MusicPlayer</name> <outputDirectory>${project.build.directory}/jlinked</outputDirectory> </launcher> </configuration> <executions> <execution> <id>create-jlink-image</id> <phase>package</phase> <goals> <goal>jlink</goal> </goals> </execution> </executions> </plugin>

然后执行:

# 先构建 jlink 镜像 mvn clean package # 再用 jpackage 打包(需 JDK 14+) jpackage --input target/jlinked/ --name MusicPlayer \ --main-class com.example.musicplayer.MainApp \ --main-jar musicplayer-1.0-SNAPSHOT.jar \ --type app-image \ --win-console \ --icon src/main/resources/icons/app.ico # Windows 需准备 ico 文件

生成的target/MusicPlayer目录下即为可执行文件夹,双击MusicPlayer.exe即可运行,无需用户安装 JDK。

从那以后我每次给学生讲 JavaFX 项目,都会先让他们跑通这个播放器,再带着他们一行行改出播放列表——因为只有亲手拖动 Slider 看到进度条实时跳动,才会真正相信“GUI 编程不是玄学”。而当你第一次把打包好的.exe发给没装 JDK 的同学,看他双击就弹出播放器界面时,那种踏实感,比任何框架文档都来得真切。希望帮到你。

本文还有配套的精品资源,点击获取

返回列表