让我们设计一个视频播放器

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

Posted by John Mactavish on May 26, 2021

如题,今天我们要开始设计一个视频播放器(当然它也能播放音乐),主要用来熟悉 Spring Boot

既然是冲着 Spring Boot 去的,语言必然用 Java;核心的视频播放功能 则选择 vlcj,一个 libvlc (你应该知道大名鼎鼎的 VLC 播放器 吧)的 Java 绑定库;JavaGUI 框架毫无疑问只能选 JavaFX,而根据 vlcj 所述, JavaFX 13 引入的原生内存缓冲(native memory bufferPixelBuffer 可以减少内存拷贝以提高性能, 所以我们一步到位,选用最新的 JavaFX 16 配合 Java 11;原生的 GUI 主题难以令人满意, 所以我们引入 Win10 风的 JavaFX 主题 JMetro

我们使用 gradle 来管理项目,构建 build.gradle 如下:

import java.util.stream.Collectors

plugins {
    id 'java'
    id 'application'
    id 'org.openjfx.javafxplugin' version '0.0.10'
}

repositories {
    // Use Maven Central for resolving dependencies.
    mavenCentral()
}

javafx {
    version = '16'
    modules = ['base', 'controls', 'fxml', 'graphics', 'media', 'swing', 'web'].stream().map {
        'javafx.' + it
    }.collect(Collectors.toList())
}

dependencies {
    implementation group: 'org.springframework.boot', name: 'spring-boot-starter', version: '2.5.0'

    ['base', 'controls', 'fxml', 'graphics', 'media', 'swing', 'web'].each {
        implementation group: 'org.openjfx', name: 'javafx-' + it, version: '16', classifier: 'win'
    }

    implementation group: 'org.jfxtras', name: 'jmetro', version: '11.6.15'

    implementation group: 'uk.co.caprica', name: 'vlcj', version: '4.7.1'

    implementation group: 'uk.co.caprica', name: 'vlcj-javafx', version: '1.0.2'

    // Use JUnit Jupiter API for testing.
    testImplementation 'org.junit.jupiter:junit-jupiter-api:5.7.1'

    // Use JUnit Jupiter Engine for testing.
    testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine'

    // This dependency is used by the application.
    implementation 'com.google.guava:guava:30.0-jre'
}

application {
    // Define the main class for the application.
    mainClass = 'fun.mactavish.mplayer.App'
}

tasks.named('test') {
    // Use junit platform for unit tests.
    useJUnitPlatform()
}

JavaFX 有好几个依赖,它们仅仅是 name 不同(但 name 还有着相同的前缀),所以我们用 Groovy 的高阶函数语法简化它们的引入。 但是仅仅引入依赖还不够,因为 JavaFX 使用 Java 9 Platform Module System, 所以我们还要按文档要求引入 gradle 插件 'org.openjfx.javafxplugin' 并进行配置。 为了在 vlcj 基础上得到 PixelBuffer 支持,我们还需在 'uk.co.caprica:vlcj' 基础上引入 'uk.co.caprica:vlcj-javafx' 依赖。

接下来让我们写个 demo 验证一下一切正常。我们写一个引导类来初始化 Spring BootJavaFX

// Launcher.java
package fun.mactavish.mplayer;

import javafx.application.Application;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class Launcher {
    public static void main(String[] args) {
        SpringApplication.run(Launcher.class, args);
        Application.launch(App.class, args);
    }
}

SpringApplication 的静态方法 run 接收一个带有 @SpringBootApplication 注解的类的类对象及命令行参数, 它会完成 Spring Boot 的初始化任务(解析配置参数、扫描组件、创建 Bean、注入依赖等)并返回一个类型为 ConfigurableApplicationContext 的 句柄,通过这个句柄可以读取配置参数、获取创建好的 Bean 等; 而 Application 的静态方法 launch 接收一个继承自 javafx.application.Application 的类的类对象及命令行参数, 它会初始化 JavaFX 并创建相关线程。

// App.java
package fun.mactavish.mplayer;

import javafx.application.Application;
import javafx.scene.Scene;
import javafx.scene.control.Label;
import javafx.scene.layout.StackPane;
import javafx.stage.Stage;
import jfxtras.styles.jmetro.JMetro;
import jfxtras.styles.jmetro.Style;

public class App extends Application {
    public App() {
    }

    @Override
    public void start(Stage primaryStage) {
        String javaVersion = System.getProperty("java.version");
        String javafxVersion = System.getProperty("javafx.version");

        Label l = new Label("Hello, JavaFX " + javafxVersion + ", running on Java " + javaVersion + ".");
        Scene scene = new Scene(new StackPane(l), 640, 480);
        new JMetro(Style.LIGHT).setScene(scene);

        primaryStage.setScene(scene);
        primaryStage.show();
    }
}

JavaFX 要求启动类不仅继承自 javafx.application.Application,还以 public 修饰自身且有 public 的无参构造函数。 在 start 方法中我们可以决定 JavaFX 初始化后干什么,这里我们先创建一个简单的 Hello World 界面。 其中仅需 new JMetro(Style.LIGHT).setScene(scene); 这么简单的一行,我们就引入了 JMetro 主题。

通过 gradle run 启动项目,应当可以看到一个 GUI 的欢迎界面,同时终端输出 Spring Boot 日志,关闭界面窗口程序即退出。 这是我们软件实现的基点。

按照 vlcj-javafx 文档的指示,我们只需引入一个新的静态方法,即可将视频播放器装载在 JavaFX 的组件 ImageView 上:

// ...
import static uk.co.caprica.vlcj.javafx.videosurface.ImageViewVideoSurfaceFactory.videoSurfaceForImageView;

// ...
        var factory = new MediaPlayerFactory();
        var player = factory.mediaPlayers().newEmbeddedMediaPlayer();
        var videoImageView = new ImageView();
        player.videoSurface().set(videoSurfaceForImageView(videoImageView));
// ...

vlcj 会把视频渲染在这个 ImageView 上,而我们可以通过改变 ImageView 的属性(长宽等)来调整视频播放器界面的属性。

var root = new BorderPane();
root.setCenter(videoImageView);
videoImageView.fitWidthProperty().bind(root.widthProperty());
videoImageView.fitHeightProperty().bind(root.heightProperty());
Scene scene = new Scene(root, 1920, 1080);

我们用一个 BorderPane 来承载播放器界面,并将其的长宽与界面的长宽绑定。如此一来,用户调整窗口大小时, 界面大小会随之变化。现在启动程序,取代欢迎界面的将是一块黑幕,这是播放器未播放任何视频时的样子。我们可以通过

mediaPlayer.media().play(url);

来播放字符串 url 指定的媒体文件。

我们可以通过 Spring Boot 的值注入特性来从配置文件中读取 url 的值。创建文件 application.properties 并写下

file=E:\\npubits\\Yes.Prime.Minister.COMPLETE.PACK.DVD.x264-P2P\\Yes.Prime.Minister.S01.DVDRip.X264-BTN\\01.mkv

file 属性指向我本地磁盘上的一个 mkv 文件。在类 App 上添加注解 @Component 并增加字段

@Value("${file}")
private String url;

理论上,程序启动后 Spring Boot 将解析 application.properties 文件并将值注入 url 字段。但是事实并非如此。 进入 debug 模式可以发现 url 其实还为 null,但是用 IDEA 的 Spring 选项卡查看,发现 App Bean 已被生成且字段已被注入值。 这是怎么回事?为什么两者相冲突呢?原来,JavaFX 的启动方法 Application.launch(App.class, args); 自己会用反射创建 App 单例, 并通过那个单例远行程序,它与 Spring Boot 自己在容器中创建的 Bean 是两个不同的对象。debug 时看到的 App 对象是 JavaFX 创建的, 自然没有值注入;Spring 选项卡中的 App 对象虽然有值注入,但不通过 JavaFX 静态方法处理就没有用。

既然 JavaFX 非要负责创建 App 对象,我们只能在它上面放弃 Spring Boot 的特性;这问题也不大,毕竟它是我们的核心类。 但是 App 单例必须能得到对 Spring Boot 的控制权(即 ApplicationContext)。于是,我们删除 Launcher 类, 转而在 App 对象的 init 方法中初始化 Spring Boot 并通过字段控制 ApplicationContext

@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) {
        var root = new BorderPane();
        var videoImageView = new ImageView();
        root.setCenter(videoImageView);
        videoImageView.fitWidthProperty().bind(root.widthProperty());
        videoImageView.fitHeightProperty().bind(root.heightProperty());
        Scene scene = new Scene(root, 1920, 1080);

        // set JMetro theme
        new JMetro(Style.LIGHT).setScene(scene);

        // mount media player
        var factory = new MediaPlayerFactory();
        var mediaPlayer = factory.mediaPlayers().newEmbeddedMediaPlayer();
        mediaPlayer.videoSurface().set(videoSurfaceForImageView(videoImageView));

        primaryStage.setTitle("MPlayer");
        primaryStage.setScene(scene);
        primaryStage.show();

        // play media whose url comes from properties
        mediaPlayer.media().play(Objects.requireNonNull(context.getEnvironment().getProperty("file")));
    }
}

注意,现在我们可以通过 ApplicationContext 来显式地获取配置文件中的属性:

mediaPlayer.media().play(Objects.requireNonNull(context.getEnvironment().getProperty("file")));   

运行一下,还是失败退出了,这又是怎么了?根据 vlcj 文档所述,原来是 mediaPlayer生命期问题JavaGC 会销毁方法局部变量 mediaPlayer 对象,导致播放器 crash。我们可以通过 App 单例的字段保存其引用, 以维持其生命期。但是有个更好的方案。我们可以用 Spring Boot 来管理它的生命期。让对象由容器创建,容器确保其始终存活; 同时我们能为其注册销毁方法,让容器在销毁 Bean 前先调用其 release 方法,释放相关联的原生资源(虽然程序退出时, 操作系统自己也会清理好)。对于无法修改源码的第三方对象,我们通过 @Configuration@Bean 组合来注册 Bean

@Configuration
class VlcjConfiguration {
    @Bean(destroyMethod = "release") // player.release() will be called on destroying
    EmbeddedMediaPlayer mediaPlayer() {
        var factory = new MediaPlayerFactory();
        var player = factory.mediaPlayers().newEmbeddedMediaPlayer();
        factory.release(); // release the factory
        return player;
    }
}

现在我们通过 context 来获取 mediaPlayer 对象:

// ...

// mount media player
EmbeddedMediaPlayer mediaPlayer = this.context.getBean(EmbeddedMediaPlayer.class);
mediaPlayer.videoSurface().set(videoSurfaceForImageView(videoImageView));

// ...

启动!一切 OK!

demo


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

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

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

contribution

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