diff --git a/app/src/bridge/index.ts b/app/src/bridge/index.ts index 01bc4e1..19bcd38 100755 --- a/app/src/bridge/index.ts +++ b/app/src/bridge/index.ts @@ -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(); + + 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"); diff --git a/ext/src/senders/media/index.ts b/ext/src/senders/media/index.ts index 2d66e32..e3526dc 100644 --- a/ext/src/senders/media/index.ts +++ b/ext/src/senders/media/index.ts @@ -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 { function getMedia (opts: InitOptions): Promise { 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 { 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 { * 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 { // If enabled, mark as active track for load request if (track.mode === "showing" || trackElement.default) { - activeTrackIds.push(index); + activeTrackIds.push(trackIndex); } + + trackIndex++; }); } }