mirror of
https://github.com/hensm/fx_cast.git
synced 2026-06-08 08:39:59 +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 mime from "mime-types";
|
||||
import path from "path";
|
||||
import stream from "stream";
|
||||
|
||||
import Media from "./Media";
|
||||
import Session from "./Session";
|
||||
@@ -199,6 +200,8 @@ async function handleMessage (message: Message) {
|
||||
clientConnection.send({ type: "CONNECT" });
|
||||
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) {
|
||||
case "bridge:/mediaServer/start": {
|
||||
const { filePath, port }
|
||||
: { 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) {
|
||||
sendMessage("mediaCast:/mediaServer/error");
|
||||
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
|
||||
if (range) {
|
||||
const bounds = range.substring(6).split("-");
|
||||
const start = parseInt(bounds[0]);
|
||||
const end = bounds[1] ? parseInt(bounds[1]) : fileSize - 1;
|
||||
mediaServer = http.createServer(async (req, res) => {
|
||||
if (!req.url) {
|
||||
return;
|
||||
}
|
||||
|
||||
res.writeHead(206, {
|
||||
"Accept-Ranges": "bytes"
|
||||
, "Content-Range": `bytes ${start}-${end}/${fileSize}`
|
||||
, "Content-Length": (end - start) + 1
|
||||
, "Content-Type": contentType
|
||||
});
|
||||
// Drop leading slash
|
||||
if (req.url.startsWith("/")) {
|
||||
req.url = req.url.slice(1);
|
||||
}
|
||||
|
||||
fs.createReadStream(filePath, { start, end }).pipe(res);
|
||||
} else {
|
||||
res.writeHead(200, {
|
||||
"Content-Length": fileSize
|
||||
, "Content-Type": contentType
|
||||
});
|
||||
switch (req.url) {
|
||||
case fileName: {
|
||||
const { range } = req.headers;
|
||||
|
||||
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", () => {
|
||||
sendMessage("mediaCast:/mediaServer/started");
|
||||
sendMessage({
|
||||
subject: "mediaCast:/mediaServer/started"
|
||||
, data: {
|
||||
mediaPath: fileName
|
||||
, subtitlePaths: Array.from(subtitles.keys())
|
||||
}
|
||||
});
|
||||
});
|
||||
mediaServer.on("close", () => {
|
||||
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) => {
|
||||
backgroundPort.postMessage({
|
||||
subject: "bridge:/mediaServer/start"
|
||||
@@ -42,7 +45,7 @@ function startMediaServer (filePath: string, port: number) {
|
||||
|
||||
switch (message.subject) {
|
||||
case "mediaCast:/mediaServer/started": {
|
||||
resolve();
|
||||
resolve(message.data);
|
||||
break;
|
||||
}
|
||||
case "mediaCast:/mediaServer/error": {
|
||||
@@ -110,8 +113,10 @@ function getSession (opts: InitOptions): Promise<cast.Session> {
|
||||
|
||||
function getMedia (opts: InitOptions): Promise<cast.media.Media> {
|
||||
return new Promise(async resolve => {
|
||||
let mediaUrlObject = new URL(opts.mediaUrl);
|
||||
const mediaTitle = mediaUrlObject.pathname;
|
||||
let mediaUrl = new URL(opts.mediaUrl);
|
||||
let subtitleUrls: URL[] = [];
|
||||
|
||||
const mediaTitle = mediaUrl.pathname;
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
// 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) {
|
||||
console.error("Failed to start media server");
|
||||
return;
|
||||
}
|
||||
|
||||
mediaUrlObject = new URL(`http://${host}:${port}/`);
|
||||
}
|
||||
|
||||
|
||||
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.metadataType = cast.media.MetadataType.GENERIC;
|
||||
mediaInfo.metadata.title = mediaTitle;
|
||||
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 instanceof HTMLVideoElement) {
|
||||
@@ -163,7 +185,7 @@ function getMedia (opts: InitOptions): Promise<cast.media.Media> {
|
||||
* and type as TrackType.TEXT.
|
||||
*/
|
||||
const castTrack = new cast.media.Track(
|
||||
index, cast.media.TrackType.TEXT);
|
||||
trackIndex, cast.media.TrackType.TEXT);
|
||||
|
||||
// Copy TextTrack properties
|
||||
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 (track.mode === "showing" || trackElement.default) {
|
||||
activeTrackIds.push(index);
|
||||
activeTrackIds.push(trackIndex);
|
||||
}
|
||||
|
||||
trackIndex++;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user