在系列的上一篇文章中,我们加入了组件 MediaScreen
与 ControlPanel
,今天我们将继续加入菜单栏(MenuBar
)。
同样的,我们把菜单栏设计为 Spring
的 Component
。为此,我们自己设计一个 MenuBar
类,继承自 JavaFX
的 MenuBar
类,
但是增加了 @Component
注解与自定义的 Menu
。
@Component
public class MenuBar extends javafx.scene.control.MenuBar {
@Autowired
MenuBar(FileMenu fileMenu, PlaybackMenu playbackMenu) {
getMenus().addAll(fileMenu, playbackMenu);
}
}
菜单栏的构成如图所示,它包括一个个的菜单(Menu
),鼠标悬浮到菜单上时会出现一个个的 MenuItem
,对应具体的功能。
以前,我们的媒体文件是硬编码的,现在则可以增加“打开文件”功能,手动选择要播放的文件。
按照 GUI 软件设计的惯例,这样的功能一般处于“文件”菜单下,于是我们增加一个 FileMenu
。
@Component
class FileMenu extends Menu {
@Autowired
FileMenu(Stage primaryStage, ApplicationEventPublisher eventPublisher){
super("File");
getItems().addAll(new MenuItem("Open File") {
{
// 不是结点
// addEventHandler(MouseEvent.MOUSE_PRESSED,event -> {
// System.out.println(primaryStage);
// new FileChooser().showOpenDialog(primaryStage);
// });
setOnAction(action -> {
var f = new FileChooser().showOpenDialog(primaryStage);
eventPublisher.publishEvent(new MediaStartEvent(f.getAbsolutePath()));
});
}
}, new MenuItem("Open Folder"){
});
}
}
我们希望,点击“打开文件”时,可以弹出一个文件选择窗口。JavaFX
中对应的组件为 FileChooser
。
所以我们为 MenuItem
注册事件回调,在回调中创建 FileChooser
让用户选择文件,最后通过 MediaStartEvent
事件通知 MediaScreen
进行播放。
注意,注释掉的事件回调写法是无效的。MenuItem
不是 Button
、Label
那样的结点,点击它不会触发 MouseEvent.MOUSE_PRESSED
(或其他鼠标事件),
我们只能用 setOnAction
来注册回调。
还有一个问题在于 FileChooser
要求传入所处 Stage
的实例。我们当然用 @Autowired
来自动注入,但是慢着…… Stage
是 JavaFX
框架创建的,
Spring
无法注入一个不由它托管的 Bean
。为此,我们必须手动注册 Stage
到 Spring
中去。而且,因为它是 FileMenu
的依赖,
这个“手动注册”必须发生在 FileMenu
初始化前。因而,我们不能再用 SpringApplication.run(App.class, commandLineArgs)
来初始化 Spring
,
这个静态方法在返回 ApplicationContext
前就初始化了所有的 Bean
(包括 FileMenu
)。我们转而这样写:
@EnableScheduling
@SpringBootApplication
public class App extends Application {
public App() {
}
private ConfigurableApplicationContext context;
public static void main(String[] commandLineArgs) {
// launch JavaFX
launch(App.class, commandLineArgs);
}
@Override
public void start(Stage primaryStage) {
String[] commandLineArgs = getParameters().getRaw().toArray(new String[0]);
var springApplication = new SpringApplication(App.class);
springApplication.addInitializers(context -> context.getBeanFactory().registerSingleton("stage", primaryStage));
this.context = springApplication.run(commandLineArgs);
Scene scene = new Scene(context.getBean(MainView.class), 1920, 1080);
// ...
primaryStage.show();
}
@Override
public void stop() {
context.close();
}
}
addInitializers
方法允许我们注册一个回调,在 ApplicationContext
准备好了而 Bean
初始化还未开始时做一些事情。
这里我们把 JavaFX
框架创建的 primaryStage
注册为 Spring
的 Bean
,以供 FileMenu
获得依赖注入。
下一步,我们在“Playback”菜单中增加丰富的媒体控制功能。 当然,作为示例,我们仅写四个:播放/暂停、停止、快进(5 秒)、快退(5 秒)。
@Component
class PlaybackMenu extends Menu {
@Autowired
PlaybackMenu(ApplicationEventPublisher publisher){
super("Playback");
getItems().addAll(
new MenuEventItem("Play/Pause", MediaPlayOrPauseEvent.INSTANCE, publisher),
new MenuEventItem("Stop", MediaStopEvent.INSTANCE, publisher),
new MenuEventItem("Jump Forward", new MediaSkipTimeEvent(5000), publisher),
new MenuEventItem("Jump Backward", new MediaSkipTimeEvent(-5000), publisher)
);
}
}
我们同样使用事件来与 MediaScreen
通信。因为这些 MenuItem
都是点击时发送指定事件的,我们索性包装一个 MenuEventItem
类。
class MenuEventItem extends MenuItem {
MenuEventItem(String name, Object event, ApplicationEventPublisher eventPublisher){
super(name);
setOnAction(e -> eventPublisher.publishEvent(event));
}
}
我们故意通过构造器传入 ApplicationEventPublisher
而不用 Spring
的依赖注入,以尽量降低与 Spring
的耦合。
这样一来,MenuEventItem
就不是 Component
,我们可以自己用 new 来创建它们。
你注意到了没有,“播放/暂停”功能触发的是事件 MediaPlayOrPauseEvent
,
其实我们用它来取代上一篇文章中引入的 MediaPauseEvent
与 MediaPlayEvent
。
上一篇文章中我们提到了,“播放/暂停”根据媒体当前状态来决定行为;上次的按钮维护了一个布尔变量来确定媒体当前状态,
但是现在的菜单栏、以后将引入的快捷键都有类似功能,我们总不能维护三份变量吧,而使用全局变量则会增加复杂度;
更要命的是,不仅“播放/暂停”功能会影响媒体当前状态,“停止”功能也会影响,这个耦合度就大了。
把“播放/暂停”合并成一个事件后,MediaScreen
可以轻易识别现在到底是要播放还是暂停,而不需要维护变量,
因为它能直接查询底层播放器状态。
@Component
public class MediaScreen extends BorderPane {
// ...
@EventListener(MediaPlayOrPauseEvent.class)
public void playOrPause() {
if (player.status().isPlaying()) {
player.controls().pause();
} else {
player.controls().play();
}
}
}
但是“播放/暂停”按钮的文本切换要怎么做呢?以前是在事件触发时维护变量以决定文本,类似于边沿触发;
现在可以转而使用实时更新策略,扩充原有的 MediaPositionUpdateEvent
为状态更新事件,按钮接收事件自动切换,类似于电平触发。
@Component
public class MediaScreen extends BorderPane {
// ...
@Scheduled(fixedRate = 200)
private void publishMediaStatus() {
eventPublisher.publishEvent(new MediaStatusUpdateEvent(player.status().position(), player.status().isPlaying()));
}
}
@Component
public class ControlPanel extends BorderPane {
// ...
@EventListener
public void onMediaStatusUpdate(MediaStatusUpdateEvent event) {
String text = event.isPlaying() ? "Pause" : "Play";
Platform.runLater(()->playOrPauseButton.setText(text));
}
}
具体的,MediaScreen
每隔 200ms 发布一个 MediaStatusUpdateEvent
,其报告媒体最新状态(播放位置、是否在播放等)。
ControlPanel
注册了这个事件的 Listener
,根据此时得知的媒体最新状态(这里仅考虑是否在播放)更新按钮的文本。
注意,对按钮的修改只能在 JavaFX
的 UI 线程中进行,而 EventListener
则在 Spring
的线程中运行,
所以必须用 JavaFX
的工具方法 Platform::runLater
将对按钮的操作提交给 UI 线程,以确保它稍后会在正确的线程中运行。
倘若直接操作按钮,你大概会得到一个
Exception in thread "scheduling-1" java.lang.IllegalStateException: Not on FX application thread
如果允许多线程操作 UI 元素(
Button
、Label
等),势必要引入同步策略(如Lock
等), 同步策略一般都会拖慢程序,而响应速度对 UI 线程至关重要,因为它直接负责用户交互。 因而,几乎所有的 GUI 框架的 UI 处理都是单线程进行的。
通过这样的改动,按钮的文本切换仍旧有较好的实时性(每 200ms 更新一次),而且菜单栏的“播放/暂停”功能也可影响到按钮, 同时依托于事件的电平触发机制保证了系统的低耦合。
最后,我们来让进度跳转变得更加用户友好一些。具体来说,我们让播放器在进度跳转完成后于屏幕右上角显示当前位置(时间)。
这可以通过底层播放器的 Marquee
来实现,我们为它设计一个方法并在进度跳转时调用:
@Component
public class MediaScreen extends BorderPane {
// ...
@EventListener
public void onProgressPositionUpdate(ProgressPositionUpdateEvent event) {
player.controls().setPosition(event.getNormalizedPosition());
showMarquee();
}
@EventListener
public void onMediaSkipTime(MediaSkipTimeEvent event) {
player.controls().skipTime(event.getTimeMs());
showMarquee();
}
private void showMarquee() {
Executors.newSingleThreadExecutor().execute(() -> {
long timeS = player.status().time() / 1000;
player.marquee().setText(String.format("%02d:%02d", timeS / 60, timeS % 60));
player.marquee().setPosition(MarqueePosition.TOP_RIGHT);
player.marquee().enable(true);
try {
Thread.sleep(1500);
} catch (InterruptedException ignored) {
}
player.marquee().enable(false);
});
}
}
我们获取当前媒体时间并把它格式化成“分:秒”的形式进行显示,记得在一段时间(1500ms)后关闭 Marquee
。
为了不影响 Spring
的事件线程,我们利用 Java
库的 Executors
把整个逻辑放到其他线程中去。
好了,组件设计告一段落,在系列的下一篇文章中我们将引入快捷键模块。
读者不妨自己试一试增加一个“音量调节”功能:其 UI 组件在
ControlPanel
上,为一个Slider
,推拽可以改变音量; 在菜单栏上也有对应的功能菜单可以实现类似的功能;音量变化时通过Marquee
显示当前音量。
本文代码在 https://github.com/gonearewe/MPlayer/tree/5ca3c1f4cec199faa3458b500b1fd0dc75dbb256 可以找到
vlcj 自己提供了一个基于 Swing 的播放器范例,叫做 vlcj-player,使用到了主要的功能,也可供参考
如果你喜欢我的文章,请我吃根冰棒吧 (o゜▽゜)o ☆
最后附上 GitHub:https://github.com/gonearewe