assjs
Version:
A lightweight JavaScript ASS subtitle renderer
227 lines (199 loc) • 6.84 kB
JavaScript
/* eslint-disable max-len */
import { compile } from 'ass-compiler';
import { $fixFontSize } from './renderer/font-size.js';
import { clear, createResize, createPlay, createPause, createSeek } from './internal.js';
import { addGlobalStyle } from './utils.js';
/**
* @typedef {Object} ASSOption
* @property {HTMLElement} [container] The container to display subtitles.
* Its style should be set with `position: relative` for subtitles will absolute to it.
* Defaults to `video.parentNode`
* @property {`${"video" | "script"}_${"width" | "height"}`} [resampling="video_height"]
* When script resolution(PlayResX and PlayResY) don't match the video resolution, this API defines how it behaves.
* However, drawings and clips will be always depending on script origin resolution.
* There are four valid values, we suppose video resolution is 1280x720 and script resolution is 640x480 in following situations:
* + `video_width`: Script resolution will set to video resolution based on video width. Script resolution will set to 640x360, and scale = 1280 / 640 = 2.
* + `video_height`(__default__): Script resolution will set to video resolution based on video height. Script resolution will set to 853.33x480, and scale = 720 / 480 = 1.5.
* + `script_width`: Script resolution will not change but scale is based on script width. So scale = 1280 / 640 = 2. This may causes top and bottom subs disappear from video area.
* + `script_height`: Script resolution will not change but scale is based on script height. So scale = 720 / 480 = 1.5. Script area will be centered in video area.
*/
export default class ASS {
#store = {
/** @type {HTMLVideoElement} */
video: null,
/** the box to display subtitles */
box: document.createElement('div'),
/**
* video resize observer
* @type {ResizeObserver}
*/
observer: null,
scale: 1,
width: 0,
height: 0,
/** resolution from ASS file, it's PlayResX and PlayResY */
scriptRes: {},
/** resolution from ASS file, it's LayoutResX and LayoutResY */
layoutRes: {},
/** resolution after resampling */
resampledRes: {},
/** current index of dialogues to match currentTime */
index: 0,
/** @type {boolean} ScaledBorderAndShadow */
sbas: true,
/** @type {import('ass-compiler').CompiledASSStyle} */
styles: {},
/** @type {import('ass-compiler').Dialogue[]} */
dialogues: [],
/**
* active dialogues
* @type {import('ass-compiler').Dialogue[]}
*/
actives: [],
/** record dialogues' position */
space: [],
requestId: 0,
delay: 0,
};
#play;
#pause;
#seek;
#resize;
/**
* Initialize an ASS instance
* @param {string} content ASS content
* @param {HTMLVideoElement} video The video element to be associated with
* @param {ASSOption} [option]
* @returns {ASS}
* @example
*
* HTML:
* ```html
* <div id="container" style="position: relative;">
* <video
* id="video"
* src="./example.mp4"
* style="position: absolute; width: 100%; height: 100%;"
* ></video>
* <!-- ASS will be added here -->
* </div>
* ```
*
* JavaScript:
* ```js
* import ASS from 'assjs';
*
* const content = await fetch('/path/to/example.ass').then((res) => res.text());
* const ass = new ASS(content, document.querySelector('#video'), {
* container: document.querySelector('#container'),
* });
* ```
*/
constructor(content, video, { container = video.parentNode, resampling } = {}) {
this.#store.video = video;
if (!container) throw new Error('Missing container.');
const { info, width, height, styles, dialogues } = compile(content);
this.#store.sbas = /yes/i.test(info.ScaledBorderAndShadow);
this.#store.layoutRes = {
width: info.LayoutResX * 1 || video.videoWidth || video.clientWidth,
height: info.LayoutResY * 1 || video.videoHeight || video.clientHeight,
};
this.#store.scriptRes = {
width: width || this.#store.layoutRes.width,
height: height || this.#store.layoutRes.height,
};
this.#store.styles = styles;
this.#store.dialogues = dialogues.map((dia) => Object.assign(dia, {
effect: ['banner', 'scroll up', 'scroll down'].includes(dia.effect?.name) ? dia.effect : null,
align: {
// 0: left, 1: center, 2: right
h: (dia.alignment + 2) % 3,
// 0: bottom, 1: center, 2: top
v: Math.trunc((dia.alignment - 1) / 3),
},
}));
if ($fixFontSize) {
container.append($fixFontSize);
}
const { box } = this.#store;
box.className = 'ASS-box';
container.append(box);
addGlobalStyle(container);
this.#play = createPlay(this.#store);
this.#pause = createPause(this.#store);
this.#seek = createSeek(this.#store);
video.addEventListener('play', this.#play);
video.addEventListener('pause', this.#pause);
video.addEventListener('playing', this.#play);
video.addEventListener('waiting', this.#pause);
video.addEventListener('seeking', this.#seek);
this.#resize = createResize(this, this.#store);
this.#resize();
this.resampling = resampling;
const observer = new ResizeObserver(this.#resize);
observer.observe(video);
this.#store.observer = observer;
return this;
}
/**
* Desctroy the ASS instance
* @returns {ASS}
*/
destroy() {
const { video, box, observer } = this.#store;
this.#pause();
clear(this.#store);
video.removeEventListener('play', this.#play);
video.removeEventListener('pause', this.#pause);
video.removeEventListener('playing', this.#play);
video.removeEventListener('waiting', this.#pause);
video.removeEventListener('seeking', this.#seek);
if ($fixFontSize) {
$fixFontSize.remove();
}
box.remove();
observer.unobserve(this.#store.video);
this.#store.styles = {};
this.#store.dialogues = [];
return this;
}
/**
* Show subtitles in the container
* @returns {ASS}
*/
show() {
this.#store.box.style.visibility = 'visible';
return this;
}
/**
* Hide subtitles in the container
* @returns {ASS}
*/
hide() {
this.#store.box.style.visibility = 'hidden';
return this;
}
#resampling = 'video_height';
/** @type {ASSOption['resampling']} */
get resampling() {
return this.#resampling;
}
set resampling(r) {
if (r === this.#resampling) return;
if (/^(video|script)_(width|height)$/.test(r)) {
this.#resampling = r;
this.#resize();
}
}
/** @type {number} Subtitle delay in seconds. */
get delay() {
return this.#store.delay;
}
set delay(d) {
if (typeof d !== 'number') return;
this.#store.delay = d;
this.#seek();
}
// addDialogue(dialogue) {
// }
}