在系列第一篇文章中,我们制作了播放器的雏形。但是可不是能放视频的就能称为播放器呢,至少要有 选择媒体文件、播放暂停控制、进度条控制等功能才行吧。今天我们就来加入一些组件。
让我们以 vlcj-player 为例,看一看一个播放器需要哪些组件。
最上面是一个菜单栏(MenuBar
),通用的控制选项就分类地放在上面。接着是播放器界面的主体 MediaScreen
。最下面则是
提供快捷功能的 ControlPanel
;其中包括一个进度条(ProgressBar
),可以显示媒体播放进度,拖动它还能进一步控制进度。
我们有必要分开设计每一个组件。在项目根目录下新建文件夹 view(意为视图),并创建一些组件:
.
├── App.java
└── view
├── ControlPanel.java
├── MainView.java
├── MediaScreen.java
└── ProgressBar.java
MenuBar
留到以后再做。
我们把播放器的组件设计为 Spring
的 Component
,稍后会看到这样做的好处。
MainView
负责承载所有的组件,由启动类 App
从 Spring
的 ApplicationContext
中加载。我们希望它能像其他 JavaFX
结点一样工作,所以让它继承自 BorderPane
并
利用构造器注入 MediaScreen
与 BorderPane
。注意设置 MediaScreen
的最小大小为 0x0
以避免窗口缩放时底部 ControlPanel
被隐藏。
@Component
public class MainView extends BorderPane {
@Autowired
MainView(MediaScreen screen, ControlPanel controlPanel) {
setCenter(screen);
// Avoid overlapping of the screen.
screen.setMinSize(0, 0);
setBottom(controlPanel);
}
}
@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 init() {
// initialize Spring Boot
String[] commandLineArgs = getParameters().getRaw().toArray(new String[0]);
this.context = SpringApplication.run(App.class, commandLineArgs);
}
@Override
public void start(Stage primaryStage) {
Scene scene = new Scene(context.getBean(MainView.class), 1920, 1080);
// set JMetro theme
new JMetro(Style.LIGHT).setScene(scene);
primaryStage.setTitle("MPlayer");
primaryStage.setMinWidth(800);
primaryStage.setMinHeight(600);
primaryStage.setScene(scene);
primaryStage.show();
}
@Override
public void stop() {
context.close();
}
}
我们把 vlcj
的细节全部封装到 MediaScreen
组件中,保证其他组件完全不知道播放的底层实现,以实现彻底解耦。
这样一来,即使换用其他媒体播放库,其他组件也无需改变,这正是“单一职责原则”的体现。看,这就是组件化设计的好处。
因为 ImageView
不会自适应地改变大小,所以 MediaScreen
继承自 BorderPane
并在内部保存一个用于播放的 ImageView
;
BorderPane
默认填充所在的结点,而内部的 ImageView
又把自己的长宽与 MediaScreen
绑定,最终播放界面也可随窗口变化而改变大小。
构造函数的最后会读取指定媒体文件,并暂停在开始时刻。
通过 @PreDestroy
注解可标记 CleanUp
方法,Spring
在销毁 Bean
前会调用该方法以释放关联的 vlcj
资源。
@Component
public class MediaScreen extends BorderPane {
private EmbeddedMediaPlayer player;
private ImageView imageView = new ImageView();
@Value("${file}")
private String url;
MediaScreen() {
this.imageView.fitWidthProperty().bind(this.widthProperty());
this.imageView.fitHeightProperty().bind(this.heightProperty());
setCenter(this.imageView);
var factory = new MediaPlayerFactory();
this.player = factory.mediaPlayers().newEmbeddedMediaPlayer();
factory.release();
player.videoSurface().set(videoSurfaceForImageView(this.imageView));
player.media().startPaused(url);
}
@PreDestroy
private void destroy() {
player.release();
}
}
测试一下此类,会在 Spring
的 Bean
创建时抛出空指针异常。调试发现,构造函数运行时,url
字段还是 null,
即还没有被注入值。这是怎么回事?可以在 StackOverflow
上找到答案,原来,Spring
对 Bean
的初始化(依赖注入、值注入等)发生在调用其
构造函数后,自然我们不该在构造函数中使用待注入的值。我们可以转而使用构造器注入:
@Component
public class MediaScreen extends BorderPane {
private EmbeddedMediaPlayer player;
private ImageView imageView = new ImageView();
MediaScreen(@Value("${file}") String url) {
// ...
player.media().startPaused(url);
}
// ...
}
不难提出一个问题,既然 MediaScreen
尽量实现了组件间高度解耦,那其他组件要怎样控制它呢(播放、暂停、快进等)?
答案是事件(event
)。现在在根目录下再新建一个文件夹 event,创建一些代表事件的类(或枚举):
.
├── App.java
├── event
│ ├── MediaPauseEvent.java
│ ├── MediaPlayEvent.java
│ ├── MediaPositionUpdateEvent.java
│ ├── MediaStopEvent.java
│ └── ProgressPositionUpdateEvent.java
└── view
├── ControlPanel.java
├── MainView.java
├── MediaScreen.java
└── ProgressBar.java
媒体播放(指继续播放)、暂停、停止事件不包含具体信息,可以用枚举写成单例:
public enum MediaPauseEvent {
INSTANCE;
}
详见设计模式概览
剩下两个事件我们稍后再看。
接下来我们可以利用 Spring
的“1.15.2 事件机制”来收发事件,这也是我们引入 @Component
的一大原因。为了发事件,可以在类中自动注入一个 ApplicationEventPublisher
;
而为了收事件,我们既可以实现泛型接口 ApplicationListener
(这样清晰地指明了自己的身份),
也可为类中的事件处理方法添加注解 @EventListener
(这样减轻了与 Spring
的耦合)。像下面这样,
ControlPanel
组件可以在控制按钮的回调函数中发布事件。Spring
会负责把它们送到位,即调用对应的 EventListener
方法。
“播放/暂停”是一个按钮,如果媒体正在播放,按下按钮将导致暂停,反之亦然。
@Component
public class ControlPanel extends BorderPane {
@Autowired
private ApplicationEventPublisher eventPublisher;
@Autowired
ControlPanel(ProgressBar progressBar) {
setTop(progressBar);
var buttons = new ButtonBar();
var playOrPause = new Button("Play");
playOrPause.setOnMouseClicked(new EventHandler<>() {
private boolean playing = false;
@Override
public void handle(final MouseEvent event) {
if (playing) {
// With media playing, pressing this button means Pause.
eventPublisher.publishEvent(MediaPauseEvent.INSTANCE);
playOrPause.setText("Play");
} else {
// With media paused, pressing this button means Play(a.k.a. Resume).
eventPublisher.publishEvent(MediaPlayEvent.INSTANCE);
playOrPause.setText("Pause");
}
playing = !playing;
}
});
var stop = new Button("Stop");
stop.setOnMouseClicked(e -> eventPublisher.publishEvent(MediaStopEvent.INSTANCE));
buttons.getButtons().add(playOrPause);
buttons.getButtons().add(stop);
setCenter(buttons);
}
}
在 MediaScreen
中可以增加一些事件处理函数。方法参数应为事件类型的,Spring
在调用它时会把发信者的事件注入参数;
如果我们不需要这个参数(比如事件是单例,我们只关心它的发生),可以声明无参方法并在注解中指明对应的事件。
@Component
public class MediaScreen extends BorderPane {
// ...
@EventListener(MediaPlayEvent.class)
public void play() {
player.controls().play();
}
@EventListener(MediaPauseEvent.class)
public void pause() {
player.controls().pause();
}
@EventListener(MediaStopEvent.class)
public void stop() {
player.controls().stop();
}
@EventListener
public void onProgressPositionUpdate(ProgressPositionUpdateEvent event) {
player.controls().setPosition(event.getNormalizedPosition());
}
}
控制组件中的进度条比较棘手,因为它涉及到双向数据传递:视频播放时进度条要同步更新,向后移动;用户自己拖动进度条时,
视频要跳转至指定位置。为此我们设计了两个事件:MediaPositionUpdateEvent
处理前者,由 MediaScreen
发给 ProgressBar
;
ProgressPositionUpdateEvent
处理后者,由 ProgressBar
发给 MediaScreen
。
进度条的自动更新必须是实时的,也就是说我们希望每隔一段时间执行一个方法。这也可通过 Spring
做到。
首先,我们要在启动类 App
上再加一个注解 @EnableScheduling
,然后就可在 MediaScreen
中利用 @Scheduled
注解增加定时方法。
如下,Spring
会每隔 1000ms 调用一次 updateProgressBar
方法,发布 MediaPositionUpdateEvent
,
其中包含的信息是当前视频的百分比进度(即一个 [0-1]
的浮点数)。
@Component
public class MediaScreen extends BorderPane {
// ...
@Scheduled(fixedRate = 1000)
private void updateProgressBar() {
eventPublisher.publishEvent(new MediaPositionUpdateEvent(player.status().position()));
}
@EventListener
public void onProgressPositionUpdate(ProgressPositionUpdateEvent event) {
player.controls().setPosition(event.getNormalizedPosition());
}
}
同时,ProgressBar
可注册一个监听值改变的回调函数,在其中发送 ProgressPositionUpdateEvent
。
@Component
class ProgressBar extends Slider {
private static final double maxValue = 1000.0;
@Autowired
private ApplicationEventPublisher eventPublisher;
ProgressBar() {
super(0.0, maxValue, 0.0);
valueProperty().addListener((observable, oldValue, newValue) -> {
eventPublisher.publishEvent(new ProgressPositionUpdateEvent((float) (newValue.floatValue() / maxValue)));
});
}
@EventListener
public void onMediaPositionUpdate(MediaPositionUpdateEvent event) {
setValue(event.getNormalizedPosition() * maxValue);
}
}
但是如果实验一下就会发现大问题,视频播放变得一卡一卡的。可以猜想到原因是,
每秒触发的 MediaPositionUpdateEvent
导致 ProgressBar::onMediaPositionUpdate
中的 setValue
方法调用,
而它自己又会触发值改变的回调函数,进而反过来又引起 MediaScreen::onProgressPositionUpdate
,
播放器反复跳转当前位置(又没那么精确)最终导致视频卡顿。
有什么避免的方法吗?一个徒劳的尝试是用 Slider
的鼠标拖动事件回调代替值改变的回调:
@Component
class ProgressBar extends Slider {
private static final double maxValue = 1000.0;
@Autowired
private ApplicationEventPublisher eventPublisher;
ProgressBar() {
super(0.0, maxValue, 0.0);
addEventHandler(MouseEvent.MOUSE_DRAGGED, event ->
eventPublisher.publishEvent(new ProgressPositionUpdateEvent((float) (getValue() / maxValue))));
}
@EventListener
public void onMediaPositionUpdate(MediaPositionUpdateEvent event) {
setValue(event.getNormalizedPosition() * maxValue);
}
}
你可以尝试一下,感觉很奇怪。只要在 Slider
上按住鼠标左键移动,即使值没有改变,这个回调也会一直触发(没错,
不是只触发一次,而是一直触发)。归根到底,这些鼠标事件是 JavaFX
的通用事件。你也可尝试一下各种鼠标事件在组件上
是何时触发的。但它们不是这里的答案。
我们还是要在值改变的回调上下功夫,仅需增加一个小变通:
@Component
class ProgressBar extends Slider {
private static final double maxValue = 1000.0;
@Autowired
private ApplicationEventPublisher eventPublisher;
private final AtomicBoolean internalRefreshing = new AtomicBoolean(false);
ProgressBar() {
super(0.0, maxValue, 0.0);
valueProperty().addListener((observable, oldValue, newValue) -> {
if (!internalRefreshing.get()) {
eventPublisher.publishEvent(new ProgressPositionUpdateEvent((float) (newValue.floatValue() / maxValue)));
}
});
}
@EventListener
public void onMediaPositionUpdate(MediaPositionUpdateEvent event) {
internalRefreshing.set(true);
setValue(event.getNormalizedPosition() * maxValue);
internalRefreshing.set(false);
}
}
布尔字段 internalRefreshing
标识这个改变是否是程序内部自动引起的(即定时同步),值改变的回调特意滤除这种情况。
显而易见,onMediaPositionUpdate
方法调用与回调的调用发生在两个不同的线程中,因此字段使用原子变量。
现在一切正常了,我们可以看看阶段性成果:
本文代码在 https://github.com/gonearewe/MPlayer/tree/3edf1dc9556ffa1d50de8cf121845a09101ad807 可以找到
vlcj 自己提供了一个基于 Swing 的播放器范例,叫做 vlcj-player,使用到了主要的功能,也可供参考
如果你喜欢我的文章,请我吃根冰棒吧 (o゜▽゜)o ☆
最后附上 GitHub:https://github.com/gonearewe