视频播放器的菜单栏

一个视频播放器的设计与实现系列(三)

Posted by John Mactavish on June 7, 2021

在系列的上一篇文章中,我们加入了组件 MediaScreenControlPanel,今天我们将继续加入菜单栏(MenuBar)。

同样的,我们把菜单栏设计为 SpringComponent。为此,我们自己设计一个 MenuBar 类,继承自 JavaFXMenuBar 类, 但是增加了 @Component 注解与自定义的 Menu

@Component
public class MenuBar extends javafx.scene.control.MenuBar {
    @Autowired
    MenuBar(FileMenu fileMenu, PlaybackMenu playbackMenu) {
        getMenus().addAll(fileMenu, playbackMenu);
    }
}

菜单栏的构成如图所示,它包括一个个的菜单(Menu),鼠标悬浮到菜单上时会出现一个个的 MenuItem,对应具体的功能。

menu

以前,我们的媒体文件是硬编码的,现在则可以增加“打开文件”功能,手动选择要播放的文件。 按照 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 不是 ButtonLabel 那样的结点,点击它不会触发 MouseEvent.MOUSE_PRESSED(或其他鼠标事件), 我们只能用 setOnAction 来注册回调。

还有一个问题在于 FileChooser 要求传入所处 Stage 的实例。我们当然用 @Autowired 来自动注入,但是慢着…… StageJavaFX 框架创建的, Spring 无法注入一个不由它托管的 Bean。为此,我们必须手动注册 StageSpring 中去。而且,因为它是 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 注册为 SpringBean,以供 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, 其实我们用它来取代上一篇文章中引入的 MediaPauseEventMediaPlayEvent。 上一篇文章中我们提到了,“播放/暂停”根据媒体当前状态来决定行为;上次的按钮维护了一个布尔变量来确定媒体当前状态, 但是现在的菜单栏、以后将引入的快捷键都有类似功能,我们总不能维护三份变量吧,而使用全局变量则会增加复杂度; 更要命的是,不仅“播放/暂停”功能会影响媒体当前状态,“停止”功能也会影响,这个耦合度就大了。

把“播放/暂停”合并成一个事件后,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 元素(ButtonLabel 等),势必要引入同步策略(如 Lock 等), 同步策略一般都会拖慢程序,而响应速度对 UI 线程至关重要,因为它直接负责用户交互。 因而,几乎所有的 GUI 框架的 UI 处理都是单线程进行的。

通过这样的改动,按钮的文本切换仍旧有较好的实时性(每 200ms 更新一次),而且菜单栏的“播放/暂停”功能也可影响到按钮, 同时依托于事件的电平触发机制保证了系统的低耦合。

最后,我们来让进度跳转变得更加用户友好一些。具体来说,我们让播放器在进度跳转完成后于屏幕右上角显示当前位置(时间)。

marquee

这可以通过底层播放器的 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 ☆

contribution

最后附上 GitHub:https://github.com/gonearewe