Home About Me

Building Chimee Plugins with React: Lessons from a Custom Control Bar

Chimee is an open-source, extensible HTML5 player framework built around components and plugins. I ended up using it for a video playback project and built several plugins on top of its plugin system. The most involved one was the control bar.

The default UI simply could not meet the design requirements—honestly, it was also pretty hard to make it look the way we wanted—so we rebuilt the whole thing as lizheing/chimee-plugin-controlbar. Along the way, a few practical patterns emerged, especially around combining Chimee with React.

What a Chimee plugin looks like

Before getting into React, it helps to understand Chimee’s basic plugin model. A minimal plugin can be written like this:

const plugin = {
  // 插件名为 controller
  name: 'controller',
  // 插件实体为按钮
  el: '<button>play</button>',
  data: {
    text: 'play'
  },
  methods: {
    changeVideoStatus () {
      this[this.text]();
    },
    changeButtonText (text) {
      this.text = text;
      this.$dom.innerText = this.text;
    }
  },
  // 在插件创建的阶段,我们为插件绑定事件。
  create () {
    this.$dom.addEventListener('click', this.changeVideoStatus);
  },
  // 插件会在播放暂停操作发生后改变自己的文案及相应的行为
  events: {
    pause () {
      this.changeButtonText('play');
    },
    play () {
      this.changeButtonText('pause');
    }
  }
};

// 安装插件
Chimee.install(plugin);
const player = new Chimee({
  // 播放地址
  src: 'http://cdn.toxicjohann.com/lostStar.mp4',
  // dom容器
  wrapper: '#wrapper',
  // 使用插件
  plugin: ['controller'],
});

From this structure, the plugin rules are fairly easy to infer:

  • A plugin exposes an object with a name property so it can be referenced when the player instance is created.
  • Template-related HTML goes into el, and the generated DOM node can be accessed through this.$dom.
  • data stores state and methods stores behavior. Both are attached to this, so they can be accessed as this.xx.
  • events defines callbacks for player events, and Chimee wires those up automatically.

At a high level, the mechanism is simple: bind data and methods onto the plugin instance, insert the HTML from el into the page, then run the relevant lifecycle hooks.

The syntax feels a little Vue-like, but under the hood this is still plain DOM work. That means two old problems show up quickly:

  • data can hold values, but it does not map directly into the HTML template in el, so sooner or later you still drop back to manual DOM manipulation.
  • Event binding has to happen after the DOM is created, inside lifecycle hooks, which means writing addEventListener calls by hand again.

Since the surrounding project was already built with React, the natural question was whether React could take over the DOM layer and remove some of that friction.

Let React render the plugin UI

React solves both of those issues nicely: rendering becomes declarative, and event handling stays inside the component tree instead of being manually attached all over the place.

What React does not solve automatically is Chimee’s plugin configuration and lifecycle model. Fully merging the two—so that a plugin is a component and a component is a plugin—would require a careful mapping between lifecycles, and that cost was too high for the project.

So the practical compromise was this: keep the outer shell as a normal Chimee plugin, and let React handle only the DOM inside it.

import React from 'react';
import ReactDOM from 'react-dom';

import App from './App';
import Context from './Context';

export default {
  Context,
  name: 'plugin-demo',
  el: `<div class="plugin"></div>`,
  create() {
    ReactDOM.render(
      <Context.Provider value={this}>
        <App />
      </Context.Provider>,
      this.$dom
    );
  }
};

This approach leaves Chimee responsible for plugin registration and mounting, while React becomes responsible for the actual UI.

Using Context to expose the plugin instance inside React

Once React is rendering the plugin UI, the next problem is access: components still need to call Chimee methods and read Chimee state.

The easiest solution is to pass the plugin instance itself—this inside the plugin—through React Context. Any child component can then consume that context and interact directly with the Chimee plugin instance.

import React, { useContext, useState, useEffect } from 'react';
import Context from '../Context';

export default function () {
  const ctx = useContext(Context);
  const [cur, setCur] = useState(0);
  useEffect(() => {
    ctx.$on('timeupdate', () => setCur(ctx.currentTime));
  }, []);

  return (
    <div className="play--time">{cur}</div>
  )
}

React Hooks make this pattern much easier to use than older Context APIs. Doing the same thing with <Context.Consumer /> and render props would have been much more awkward.

Of course, Context is mainly useful when the component hierarchy gets deep and passing props becomes tedious. If the plugin UI is shallow, regular props work fine too. Context just keeps the wiring cleaner.

Handling external async data updates

With this setup, day-to-day plugin development becomes much more comfortable. But asynchronous data introduces another limitation.

A Chimee plugin’s configuration is effectively fixed at the moment new Chimee() runs. If external business data changes later, that does not automatically update the plugin config, and therefore does not trigger a rerender through Chimee. There is no lifecycle comparable to componentWillReceiveProps() for this case.

That means if Chimee itself cannot react to updated configuration, React nested inside that plugin will not get a new input from Chimee either.

+--------+     +--------+     +---------------+     +-----------------+
| config +---->+ Chimee +---->+ Chimee Plugin +---->+ React Component |
+--------+     +----+---+     +---------------+     +-----------------+
                    ×
+----------------+  |
| updated config +--×
+----------------+

This became a real issue in one specific requirement: the control bar had to display the title and cover image of the next video. That data came from an API call in the business layer, so it was unavailable when the player was first instantiated.

The solution was to stop treating updated data as configuration and instead pass it through events. In practice, custom events became the bridge for cross-layer communication.

+--------+     +--------+     +---------------+     +-----------------+
| config +---->+ Chimee +---->+ Chimee Plugin +---->+ React Component |
+--------+     +--------+     +---------------+     +--------+--------+
                                                             ^
+----------------+this.$emit('customEvent')+-----------------+--------+
| updated config +------------------------>+ this.$on('customeEvent') |
+----------------+                         +--------------------------+

In other words, once new data arrives outside the player, it can be pushed in with this.$emit(...), listened to inside the plugin with this.$on(...), and then forwarded into React state however the component chooses.

Controlling execution order across multiple plugins

As the player grew more complex, multiple plugins started responding to the same event. One of the trickiest cases happened at the end of a video:

  • first, play an end-card ad,
  • then show a countdown,
  • and only after that start the next video.

The challenge was making sure all three steps ran in the correct order when they were all tied to the same event.

Fortunately, Chimee’s plugin system already accounts for this. If an earlier callback returns false, it can block the event chain. After that plugin finishes its own task, it can emit the event again and allow the rest of the flow to continue.

import React, {useEffect} from 'react';
import Context from './context';

export default function() {
  const ctx = useContext(Context);

  useEffect(() => {
    const onEnded = event => {
      if(event === 'manual') {
        return true;
      }

      setTimeout(() => ctx.$emit('ended', 'manual'), 10000);
      return false;
    };

    ctx.$on('ended', onEnded);
    return () => ctx.$off('ended', onEnded);
  });

  return (<div>推迟10秒后结束</div>);
}

The sequence here is:

  • when ended fires, the plugin intercepts it,
  • return false prevents the rest of the event flow from continuing,
  • after the delayed task is complete, ctx.$emit('ended', 'manual') triggers the event again.

There is one important detail: the second emit will also run the callback registered by the current plugin. Without a guard, that would loop back into the same logic. That is why the extra argument is added—to let the callback detect that the event was re-triggered by the plugin itself and skip the blocking behavior the second time.

What this setup ultimately changes

Chimee’s plugin system is flexible enough to support this style of development well. Once React takes over the rendering layer, the official data, methods, and events fields become much less central than they appear at first.

In practice:

  • data is replaced by React state,
  • methods move naturally into component logic,
  • and events are often handled manually inside React components through ctx.$on and ctx.$off.

That tradeoff worked well for plugin-heavy video features, especially for something as involved as a custom control bar. It preserves Chimee’s extensibility while avoiding most of the pain of direct DOM manipulation.