视频播放器的组件化设计

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

Posted by John Mactavish on June 2, 2021

在系列第一篇文章中,我们制作了播放器的雏形。但是可不是能放视频的就能称为播放器呢,至少要有 选择媒体文件、播放暂停控制、进度条控制等功能才行吧。今天我们就来加入一些组件。

让我们以 vlcj-player 为例,看一看一个播放器需要哪些组件。

layout

最上面是一个菜单栏(MenuBar),通用的控制选项就分类地放在上面。接着是播放器界面的主体 MediaScreen。最下面则是 提供快捷功能的 ControlPanel;其中包括一个进度条(ProgressBar),可以显示媒体播放进度,拖动它还能进一步控制进度。

我们有必要分开设计每一个组件。在项目根目录下新建文件夹 view(意为视图),并创建一些组件:

.
├── App.java
└── view
    ├── ControlPanel.java
    ├── MainView.java
    ├── MediaScreen.java
    └── ProgressBar.java

MenuBar 留到以后再做。

我们把播放器的组件设计为 SpringComponent,稍后会看到这样做的好处。

MainView 负责承载所有的组件,由启动类 AppSpringApplicationContext 中加载。我们希望它能像其他 JavaFX 结点一样工作,所以让它继承自 BorderPane 并 利用构造器注入 MediaScreenBorderPane。注意设置 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 并在内部保存一个用于播放的 ImageViewBorderPane 默认填充所在的结点,而内部的 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();
    }
}

测试一下此类,会在 SpringBean 创建时抛出空指针异常。调试发现,构造函数运行时,url 字段还是 null, 即还没有被注入值。这是怎么回事?可以在 StackOverflow 上找到答案,原来,SpringBean 的初始化(依赖注入、值注入等)发生在调用其 构造函数后,自然我们不该在构造函数中使用待注入的值。我们可以转而使用构造器注入:

@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 发给 ProgressBarProgressPositionUpdateEvent 处理后者,由 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 方法调用与回调的调用发生在两个不同的线程中,因此字段使用原子变量。

现在一切正常了,我们可以看看阶段性成果:

components-demo


本文代码在 https://github.com/gonearewe/MPlayer/tree/3edf1dc9556ffa1d50de8cf121845a09101ad807 可以找到

vlcj 自己提供了一个基于 Swing 的播放器范例,叫做 vlcj-player,使用到了主要的功能,也可供参考

如果你喜欢我的文章,请我吃根冰棒吧 (o゜▽゜)o ☆

contribution

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