mirror of
https://github.com/hensm/fx_cast.git
synced 2026-06-12 18:39:58 +00:00
Add local .srt subtitle support
This commit is contained in:
@@ -6,6 +6,7 @@ import fs from "fs";
|
|||||||
import http from "http";
|
import http from "http";
|
||||||
import mime from "mime-types";
|
import mime from "mime-types";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
|
import stream from "stream";
|
||||||
|
|
||||||
import Media from "./Media";
|
import Media from "./Media";
|
||||||
import Session from "./Session";
|
import Session from "./Session";
|
||||||
@@ -199,6 +200,8 @@ async function handleMessage (message: Message) {
|
|||||||
clientConnection.send({ type: "CONNECT" });
|
clientConnection.send({ type: "CONNECT" });
|
||||||
clientReceiver.send({ type: "STOP", requestId: 1 });
|
clientReceiver.send({ type: "STOP", requestId: 1 });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -279,53 +282,181 @@ function handleReceiverSelectorMessage (message: Message) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleMediaServerMessage (message: Message) {
|
async function handleMediaServerMessage (message: Message) {
|
||||||
|
async function convertSrtToVtt (srtFilePath: string) {
|
||||||
|
const fileStream = fs.createReadStream(
|
||||||
|
srtFilePath, { encoding: "utf-8" });
|
||||||
|
|
||||||
|
let fileContents = "";
|
||||||
|
for await (let chunk of fileStream) {
|
||||||
|
// Omit BOM if present
|
||||||
|
if (!fileContents && chunk[0] === "\uFEFF") {
|
||||||
|
chunk = chunk.slice(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalize line endings
|
||||||
|
fileContents += chunk.replace(/$\r\n/gm, "\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
let vttText = "WEBVTT\n";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Matches a caption group within an SubRip file. Match groups
|
||||||
|
* are the index (followed by a new line), the time range
|
||||||
|
* (followed by a new line), then any text content until a blank
|
||||||
|
* line.
|
||||||
|
*/
|
||||||
|
const REGEX_CAPTION = /(?:(\d+)\n(\d{2}:\d{2}:\d{2},\d{3} --> \d{2}:\d{2}:\d{2},\d{3}))\n((?:.+)\n?)*/g;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WebVTT is very similar to SubRip, the main differences being
|
||||||
|
* the "WEBVTT" specifier and optional metadata at the head of
|
||||||
|
* the file, the optional caption indicies and the timecode
|
||||||
|
* millisecond separator.
|
||||||
|
*/
|
||||||
|
for (const groups of fileContents.matchAll(REGEX_CAPTION)) {
|
||||||
|
const captionSource = groups[0];
|
||||||
|
const captionIndex = groups[1];
|
||||||
|
const captionTime = groups[2];
|
||||||
|
const captionText = groups[3];
|
||||||
|
|
||||||
|
vttText += `\n${captionIndex}\n`;
|
||||||
|
vttText += `${captionTime.replace(/,/g, ".")}\n`;
|
||||||
|
|
||||||
|
if (captionText) {
|
||||||
|
vttText += `${captionText}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return vttText;
|
||||||
|
}
|
||||||
|
|
||||||
switch (message.subject) {
|
switch (message.subject) {
|
||||||
case "bridge:/mediaServer/start": {
|
case "bridge:/mediaServer/start": {
|
||||||
const { filePath, port }
|
const { filePath, port }
|
||||||
: { filePath: string, port: number } = message.data;
|
: { filePath: string, port: number } = message.data;
|
||||||
|
|
||||||
const contentType = mime.lookup(filePath);
|
if (mediaServer && mediaServer.listening) {
|
||||||
|
mediaServer.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
let fileDir: string;
|
||||||
|
let fileName: string;
|
||||||
|
let fileSize: number;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const stat = await fs.promises.lstat(filePath);
|
||||||
|
|
||||||
|
if (stat.isFile()) {
|
||||||
|
fileDir = path.dirname(filePath);
|
||||||
|
fileName = path.basename(filePath);
|
||||||
|
fileSize = stat.size;
|
||||||
|
} else {
|
||||||
|
console.error("Error: Media path is not a file.");
|
||||||
|
sendMessage("mediaCast:/mediaServer/error");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Error: Failed to find media path.");
|
||||||
|
sendMessage("mediaCast:/mediaServer/error");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentType = mime.lookup(filePath);
|
||||||
if (!contentType) {
|
if (!contentType) {
|
||||||
sendMessage("mediaCast:/mediaServer/error");
|
sendMessage("mediaCast:/mediaServer/error");
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (mediaServer && mediaServer.listening) {
|
|
||||||
mediaServer.close();
|
// file name -> file contents
|
||||||
|
const subtitles = new Map<string, string>();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const dirEntries = await fs.promises.readdir(
|
||||||
|
fileDir, { withFileTypes: true });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find any SubRip files within the same directory and
|
||||||
|
* convert to WebVTT source.
|
||||||
|
*/
|
||||||
|
for (const dirEntry of dirEntries) {
|
||||||
|
if (dirEntry.isFile()
|
||||||
|
&& mime.lookup(dirEntry.name) === "application/x-subrip") {
|
||||||
|
|
||||||
|
subtitles.set(dirEntry.name, await convertSrtToVtt(
|
||||||
|
path.join(fileDir, dirEntry.name)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// Subtitles optional
|
||||||
}
|
}
|
||||||
|
|
||||||
mediaServer = http.createServer((req, res) => {
|
|
||||||
const { size: fileSize } = fs.statSync(filePath);
|
|
||||||
const { range } = req.headers;
|
|
||||||
|
|
||||||
// Partial content HTTP 206
|
mediaServer = http.createServer(async (req, res) => {
|
||||||
if (range) {
|
if (!req.url) {
|
||||||
const bounds = range.substring(6).split("-");
|
return;
|
||||||
const start = parseInt(bounds[0]);
|
}
|
||||||
const end = bounds[1] ? parseInt(bounds[1]) : fileSize - 1;
|
|
||||||
|
|
||||||
res.writeHead(206, {
|
// Drop leading slash
|
||||||
"Accept-Ranges": "bytes"
|
if (req.url.startsWith("/")) {
|
||||||
, "Content-Range": `bytes ${start}-${end}/${fileSize}`
|
req.url = req.url.slice(1);
|
||||||
, "Content-Length": (end - start) + 1
|
}
|
||||||
, "Content-Type": contentType
|
|
||||||
});
|
|
||||||
|
|
||||||
fs.createReadStream(filePath, { start, end }).pipe(res);
|
switch (req.url) {
|
||||||
} else {
|
case fileName: {
|
||||||
res.writeHead(200, {
|
const { range } = req.headers;
|
||||||
"Content-Length": fileSize
|
|
||||||
, "Content-Type": contentType
|
|
||||||
});
|
|
||||||
|
|
||||||
fs.createReadStream(filePath).pipe(res);
|
// Partial content HTTP 206
|
||||||
|
if (range) {
|
||||||
|
const bounds = range.substring(6).split("-");
|
||||||
|
const start = parseInt(bounds[0]);
|
||||||
|
const end = bounds[1]
|
||||||
|
? parseInt(bounds[1]) : fileSize - 1;
|
||||||
|
|
||||||
|
res.writeHead(206, {
|
||||||
|
"Accept-Ranges": "bytes"
|
||||||
|
, "Content-Range": `bytes ${start}-${end}/${fileSize}`
|
||||||
|
, "Content-Length": (end - start) + 1
|
||||||
|
, "Content-Type": contentType
|
||||||
|
});
|
||||||
|
|
||||||
|
fs.createReadStream(
|
||||||
|
filePath, { start, end }).pipe(res);
|
||||||
|
} else {
|
||||||
|
res.writeHead(200, {
|
||||||
|
"Content-Length": fileSize
|
||||||
|
, "Content-Type": contentType
|
||||||
|
});
|
||||||
|
|
||||||
|
fs.createReadStream(filePath).pipe(res);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
default: {
|
||||||
|
if (subtitles.has(req.url)) {
|
||||||
|
const vttSource = subtitles.get(req.url)!;
|
||||||
|
const vttStream = stream.Readable.from(vttSource);
|
||||||
|
|
||||||
|
vttStream.pipe(res);
|
||||||
|
}
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
mediaServer.on("listening", () => {
|
mediaServer.on("listening", () => {
|
||||||
sendMessage("mediaCast:/mediaServer/started");
|
sendMessage({
|
||||||
|
subject: "mediaCast:/mediaServer/started"
|
||||||
|
, data: {
|
||||||
|
mediaPath: fileName
|
||||||
|
, subtitlePaths: Array.from(subtitles.keys())
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
mediaServer.on("close", () => {
|
mediaServer.on("close", () => {
|
||||||
sendMessage("mediaCast:/mediaServer/stopped");
|
sendMessage("mediaCast:/mediaServer/stopped");
|
||||||
|
|||||||
@@ -23,7 +23,10 @@ function getLocalAddress () {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function startMediaServer (filePath: string, port: number) {
|
function startMediaServer (filePath: string, port: number)
|
||||||
|
: Promise<{ mediaPath: string
|
||||||
|
, subtitlePaths: string[] }> {
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
backgroundPort.postMessage({
|
backgroundPort.postMessage({
|
||||||
subject: "bridge:/mediaServer/start"
|
subject: "bridge:/mediaServer/start"
|
||||||
@@ -42,7 +45,7 @@ function startMediaServer (filePath: string, port: number) {
|
|||||||
|
|
||||||
switch (message.subject) {
|
switch (message.subject) {
|
||||||
case "mediaCast:/mediaServer/started": {
|
case "mediaCast:/mediaServer/started": {
|
||||||
resolve();
|
resolve(message.data);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "mediaCast:/mediaServer/error": {
|
case "mediaCast:/mediaServer/error": {
|
||||||
@@ -110,8 +113,10 @@ function getSession (opts: InitOptions): Promise<cast.Session> {
|
|||||||
|
|
||||||
function getMedia (opts: InitOptions): Promise<cast.media.Media> {
|
function getMedia (opts: InitOptions): Promise<cast.media.Media> {
|
||||||
return new Promise(async resolve => {
|
return new Promise(async resolve => {
|
||||||
let mediaUrlObject = new URL(opts.mediaUrl);
|
let mediaUrl = new URL(opts.mediaUrl);
|
||||||
const mediaTitle = mediaUrlObject.pathname;
|
let subtitleUrls: URL[] = [];
|
||||||
|
|
||||||
|
const mediaTitle = mediaUrl.pathname;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* If the media is a local file, start an HTTP media server
|
* If the media is a local file, start an HTTP media server
|
||||||
@@ -123,24 +128,41 @@ function getMedia (opts: InitOptions): Promise<cast.media.Media> {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
// Wait until media server is listening
|
// Wait until media server is listening
|
||||||
await startMediaServer(mediaUrlObject.pathname, port);
|
const { mediaPath, subtitlePaths }
|
||||||
|
= await startMediaServer(mediaTitle, port);
|
||||||
|
|
||||||
|
const baseUrl = new URL(`http://${host}:${port}/`);
|
||||||
|
mediaUrl = new URL(mediaPath, baseUrl)
|
||||||
|
subtitleUrls = subtitlePaths.map(
|
||||||
|
path => new URL(path, baseUrl));
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to start media server");
|
console.error("Failed to start media server");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
mediaUrlObject = new URL(`http://${host}:${port}/`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
const activeTrackIds: number[] = [];
|
const activeTrackIds: number[] = [];
|
||||||
const mediaInfo = new cast.media.MediaInfo(mediaUrlObject.href, null);
|
const mediaInfo = new cast.media.MediaInfo(mediaUrl.href, null);
|
||||||
|
|
||||||
mediaInfo.metadata = new cast.media.GenericMediaMetadata();
|
mediaInfo.metadata = new cast.media.GenericMediaMetadata();
|
||||||
mediaInfo.metadata.metadataType = cast.media.MetadataType.GENERIC;
|
mediaInfo.metadata.metadataType = cast.media.MetadataType.GENERIC;
|
||||||
mediaInfo.metadata.title = mediaTitle;
|
mediaInfo.metadata.title = mediaTitle;
|
||||||
mediaInfo.tracks = [];
|
mediaInfo.tracks = [];
|
||||||
|
|
||||||
|
let trackIndex = 0;
|
||||||
|
for (const subtitleUrl of subtitleUrls) {
|
||||||
|
const castTrack = new cast.media.Track(
|
||||||
|
trackIndex, cast.media.TrackType.TEXT);
|
||||||
|
|
||||||
|
castTrack.name = subtitleUrl.pathname;
|
||||||
|
castTrack.trackContentId = subtitleUrl.href;
|
||||||
|
castTrack.trackContentType = "text/vtt";
|
||||||
|
castTrack.subtype = cast.media.TextTrackType.SUBTITLES;
|
||||||
|
|
||||||
|
mediaInfo.tracks.push(castTrack);
|
||||||
|
}
|
||||||
|
|
||||||
if (mediaElement) {
|
if (mediaElement) {
|
||||||
if (mediaElement instanceof HTMLVideoElement) {
|
if (mediaElement instanceof HTMLVideoElement) {
|
||||||
@@ -163,7 +185,7 @@ function getMedia (opts: InitOptions): Promise<cast.media.Media> {
|
|||||||
* and type as TrackType.TEXT.
|
* and type as TrackType.TEXT.
|
||||||
*/
|
*/
|
||||||
const castTrack = new cast.media.Track(
|
const castTrack = new cast.media.Track(
|
||||||
index, cast.media.TrackType.TEXT);
|
trackIndex, cast.media.TrackType.TEXT);
|
||||||
|
|
||||||
// Copy TextTrack properties
|
// Copy TextTrack properties
|
||||||
castTrack.name = track.label;
|
castTrack.name = track.label;
|
||||||
@@ -204,8 +226,10 @@ function getMedia (opts: InitOptions): Promise<cast.media.Media> {
|
|||||||
|
|
||||||
// If enabled, mark as active track for load request
|
// If enabled, mark as active track for load request
|
||||||
if (track.mode === "showing" || trackElement.default) {
|
if (track.mode === "showing" || trackElement.default) {
|
||||||
activeTrackIds.push(index);
|
activeTrackIds.push(trackIndex);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
trackIndex++;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user